Extending Bob Query Builder
Bob Query Builder is designed to be extensible, allowing you to add custom functionality without modifying the core library. This makes it perfect for domain-specific use cases like WordPress, Laravel applications, or any custom PHP project.
Table of Contents
Macros
Macros allow you to add custom methods to the Builder class at runtime.
Basic Macro Example
use Bob\Query\Builder;
// Register a custom method
Builder::macro('whereActive', function() {
return $this->where('status', '=', 'active');
});
// Use it anywhere
$users = $connection->table('users')
->whereActive()
->get();Complex Macro Example
// Add a method to find records created in the last N days
Builder::macro('whereRecentDays', function($days = 7) {
$date = date('Y-m-d', strtotime("-{$days} days"));
return $this->where('created_at', '>=', $date);
});
// Usage
$recentPosts = $connection->table('posts')
->whereRecentDays(30)
->orderBy('created_at', 'desc')
->get();Registering Multiple Macros
Builder::mixin([
'wherePublished' => function() {
return $this->where('status', 'published');
},
'whereDraft' => function() {
return $this->where('status', 'draft');
},
'whereAuthor' => function($authorId) {
return $this->where('author_id', $authorId);
}
]);Query Scopes
Scopes allow you to define reusable query constraints that can be applied globally or locally.
Local Scopes
Local scopes are applied on-demand:
// Register a local scope
Builder::scope('popular', function() {
return $this->where('views', '>', 1000)
->orderBy('views', 'desc');
});
// Use the scope
$popularPosts = $connection->table('posts')
->withScope('popular')
->limit(10)
->get();Global Scopes
Global scopes are automatically applied to all queries:
// Register a global scope (e.g., soft deletes)
Builder::globalScope('notDeleted', function() {
return $this->whereNull('deleted_at');
});
// All queries will automatically exclude soft-deleted records
$users = $connection->table('users')->get(); // Automatically adds WHERE deleted_at IS NULL
// To include soft-deleted records, remove the global scope
$allUsers = $connection->table('users')
->withoutGlobalScope('notDeleted')
->get();Parameterized Scopes
Builder::scope('ofType', function($type) {
return $this->where('type', $type);
});
Builder::scope('betweenDates', function($start, $end) {
return $this->whereBetween('created_at', [$start, $end]);
});
// Usage
$products = $connection->table('products')
->withScope('ofType', 'electronics')
->withScope('betweenDates', '2024-01-01', '2024-12-31')
->get();Dynamic Finders
Dynamic finders provide intuitive method names for common query patterns.
Built-in Dynamic Finders
Bob comes with several built-in dynamic finder patterns:
// Find a single record by column
$user = $connection->table('users')->findByEmail('user@example.com');
// Find all records by column
$posts = $connection->table('posts')->findAllByStatus('published');
// Add where conditions dynamically
$query = $connection->table('products')
->whereByCategory('electronics')
->whereByBrand('Apple');
// Count by column
$count = $connection->table('orders')->countByStatus('pending');
// Check existence
$exists = $connection->table('users')->existsByEmail('test@example.com');
// Delete by column
$deleted = $connection->table('logs')->deleteByCreatedAt('2023-01-01');
// Order dynamically
$posts = $connection->table('posts')
->orderByCreatedAtDesc()
->orderByTitleAsc()
->get();Custom Dynamic Finders
Register your own dynamic finder patterns:
// Register a custom finder for slug-based lookups
Builder::registerFinder('/^findBySlug(.+)$/', function($matches, $params) {
$slug = $params[0] ?? null;
return $this->where('slug', '=', $slug)->first();
});
// Register a finder for status-based queries
Builder::registerFinder('/^whereStatus(.+)$/', function($matches, $params) {
$status = strtolower($matches[1]);
return $this->where('status', '=', $status);
});
// Usage
$post = $connection->table('posts')->findBySlugMyAwesomePost('my-awesome-post');
$published = $connection->table('posts')->whereStatusPublished()->get();WordPress Extension Example
Here's a complete example of extending Bob for WordPress usage:
<?php
namespace YourProject\Database;
use Bob\Query\Builder;
use Bob\Database\Connection;
class WordPressExtension
{
/**
* Register WordPress-specific extensions
*/
public static function register(): void
{
self::registerMacros();
self::registerScopes();
self::registerFinders();
}
protected static function registerMacros(): void
{
// WordPress post queries
Builder::mixin([
'wherePublished' => function() {
return $this->where('post_status', 'publish');
},
'whereDraft' => function() {
return $this->where('post_status', 'draft');
},
'wherePostType' => function($type) {
return $this->where('post_type', $type);
},
'whereMetaKey' => function($key, $value = null, $compare = '=') {
$this->join('postmeta', 'posts.ID', '=', 'postmeta.post_id');
$this->where('postmeta.meta_key', $key);
if ($value !== null) {
$this->where('postmeta.meta_value', $compare, $value);
}
return $this;
},
'withMeta' => function() {
return $this->leftJoin('postmeta', 'posts.ID', '=', 'postmeta.post_id')
->select('posts.*', 'postmeta.meta_key', 'postmeta.meta_value');
},
'withAuthor' => function() {
return $this->join('users', 'posts.post_author', '=', 'users.ID')
->addSelect('users.display_name as author_name');
},
'withCommentCount' => function() {
return $this->leftJoin('comments', function($join) {
$join->on('posts.ID', '=', 'comments.comment_post_ID')
->where('comments.comment_approved', '=', '1');
})->selectRaw('posts.*, COUNT(comments.comment_ID) as comment_count')
->groupBy('posts.ID');
}
]);
// WooCommerce specific
Builder::macro('whereInStock', function() {
return $this->whereMetaKey('_stock_status', 'instock');
});
Builder::macro('whereProductType', function($type) {
return $this->join('term_relationships', 'posts.ID', '=', 'term_relationships.object_id')
->join('term_taxonomy', 'term_relationships.term_taxonomy_id', '=', 'term_taxonomy.term_taxonomy_id')
->join('terms', 'term_taxonomy.term_id', '=', 'terms.term_id')
->where('term_taxonomy.taxonomy', 'product_type')
->where('terms.slug', $type);
});
}
protected static function registerScopes(): void
{
// Global scope to respect WordPress table prefix
Builder::globalScope('wpPrefix', function() {
// This would be handled by Connection configuration
// Just an example of how you might use global scopes
});
// Local scope for post queries
Builder::scope('published', function() {
return $this->where('post_status', 'publish')
->whereNotNull('post_date')
->where('post_date', '<=', current_time('mysql'));
});
// Scope for specific post types
Builder::scope('posts', function() {
return $this->where('post_type', 'post');
});
Builder::scope('pages', function() {
return $this->where('post_type', 'page');
});
Builder::scope('attachments', function() {
return $this->where('post_type', 'attachment');
});
}
protected static function registerFinders(): void
{
// Find by slug
Builder::registerFinder('/^findBySlug$/', function($matches, $params) {
$slug = $params[0] ?? null;
return $this->where('post_name', '=', $slug)->first();
});
// Find by post meta
Builder::registerFinder('/^findByMeta(.+)$/', function($matches, $params) {
$metaKey = $this->camelToSnake($matches[1]);
$metaValue = $params[0] ?? null;
return $this->join('postmeta', 'posts.ID', '=', 'postmeta.post_id')
->where('postmeta.meta_key', $metaKey)
->where('postmeta.meta_value', $metaValue)
->first();
});
// Where by post status
Builder::registerFinder('/^whereStatus(.+)$/', function($matches, $params) {
$status = strtolower($matches[1]);
return $this->where('post_status', $status);
});
}
}
// Usage in your WordPress plugin/theme
WordPressExtension::register();
// Now you can use WordPress-specific methods
$connection = new Connection([
'driver' => 'mysql',
'host' => DB_HOST,
'database' => DB_NAME,
'username' => DB_USER,
'password' => DB_PASSWORD,
'charset' => DB_CHARSET,
'prefix' => $wpdb->prefix
]);
// Use the extended functionality
$posts = $connection->table('posts')
->wherePublished()
->wherePostType('post')
->withAuthor()
->withCommentCount()
->orderBy('post_date', 'desc')
->limit(10)
->get();
$product = $connection->table('posts')
->wherePostType('product')
->whereInStock()
->findBySlug('awesome-product');
$pageWithMeta = $connection->table('posts')
->wherePostType('page')
->findByMetaCustomField('special-value');Creating Your Own Extension Package
You can create a reusable extension package for Bob:
1. Create Your Extension Class
<?php
namespace YourVendor\BobExtension;
use Bob\Query\Builder;
class MyExtension
{
protected static bool $registered = false;
public static function register(): void
{
if (static::$registered) {
return;
}
static::registerMacros();
static::registerScopes();
static::registerFinders();
static::$registered = true;
}
public static function unregister(): void
{
Builder::clearMacros();
Builder::clearScopes();
Builder::clearFinders();
static::$registered = false;
}
protected static function registerMacros(): void
{
// Your custom macros
}
protected static function registerScopes(): void
{
// Your custom scopes
}
protected static function registerFinders(): void
{
// Your custom finders
}
}2. Create a Service Provider (if using a framework)
<?php
namespace YourVendor\BobExtension;
class BobExtensionServiceProvider
{
public function register(): void
{
MyExtension::register();
}
public function boot(): void
{
// Additional bootstrapping if needed
}
}3. Package Structure
your-bob-extension/
├── src/
│ ├── MyExtension.php
│ └── Providers/
│ └── BobExtensionServiceProvider.php
├── tests/
│ └── ExtensionTest.php
├── composer.json
└── README.md4. Composer.json Example
{
"name": "your-vendor/bob-wordpress-extension",
"description": "WordPress extension for Bob Query Builder",
"require": {
"php": "^8.1",
"marwen-brini/bob-the-builder": "^1.0"
},
"autoload": {
"psr-4": {
"YourVendor\\BobExtension\\": "src/"
}
},
"extra": {
"bob": {
"register": "YourVendor\\BobExtension\\MyExtension::register"
}
}
}Testing Your Extensions
Bob Query Builder uses Pest for testing. Here's how to write tests for your extensions:
Setting Up Tests with Pest
use Bob\Query\Builder;
use Bob\Database\Connection;
beforeEach(function () {
// Setup database connection
$this->connection = new Connection([
'driver' => 'sqlite',
'database' => ':memory:'
]);
// Clear any existing extensions
Builder::clearMacros();
Builder::clearScopes();
Builder::clearFinders();
});
afterEach(function () {
// Clean up extensions after each test
Builder::clearMacros();
Builder::clearScopes();
Builder::clearFinders();
});Testing Macros
test('can register and use custom macros', function () {
// Register a macro
Builder::macro('whereActive', function() {
return $this->where('status', '=', 'active');
});
// Test that the macro exists
expect(Builder::hasMacro('whereActive'))->toBeTrue();
// Use the macro
$query = $this->connection->table('users')->whereActive();
$sql = $query->toSql();
expect($sql)->toContain('status');
expect($query->getBindings())->toContain('active');
});
test('can chain multiple macros', function () {
Builder::macro('active', function() {
return $this->where('status', '=', 'active');
});
Builder::macro('recent', function($days = 7) {
$date = date('Y-m-d', strtotime("-{$days} days"));
return $this->where('created_at', '>=', $date);
});
$query = $this->connection->table('users')
->active()
->recent(30);
expect($query->toSql())->toContain('status')
->and($query->toSql())->toContain('created_at');
});Testing Scopes
test('can use local scopes', function () {
Builder::scope('published', function() {
return $this->where('status', '=', 'published');
});
expect(Builder::hasScope('published'))->toBeTrue();
$query = $this->connection->table('posts')
->withScope('published');
expect($query->getBindings())->toContain('published');
});
test('can use parameterized scopes', function () {
Builder::scope('ofType', function($type) {
return $this->where('type', '=', $type);
});
$query = $this->connection->table('products')
->withScope('ofType', 'electronics');
expect($query->getBindings())->toContain('electronics');
});Testing Dynamic Finders
test('can use dynamic finders', function () {
// Create test table
$this->connection->statement('
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT,
status TEXT
)
');
$this->connection->table('users')->insert([
['email' => 'test@example.com', 'status' => 'active']
]);
$user = $this->connection->table('users')
->findByEmail('test@example.com');
expect($user)->toBeArray()
->and($user['email'])->toBe('test@example.com');
});
test('can register custom finder patterns', function () {
Builder::registerFinder('/^getBySlug$/', function($matches, $params) {
$slug = $params[0] ?? null;
return $this->where('slug', '=', $slug)->first();
});
// Mock the behavior
$query = $this->connection->table('posts');
expect(fn() => $query->getBySlug('test-slug'))
->not->toThrow();
});Testing Chain Combinations
test('can chain multiple extensions together', function () {
// Setup test data
$this->connection->statement('
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
email TEXT,
status TEXT,
created_at TEXT
)
');
$this->connection->table('users')->insert([
['name' => 'Active User', 'email' => 'active@example.com', 'status' => 'active', 'created_at' => date('Y-m-d')],
['name' => 'Old User', 'email' => 'old@example.com', 'status' => 'active', 'created_at' => '2020-01-01'],
['name' => 'Inactive User', 'email' => 'inactive@example.com', 'status' => 'inactive', 'created_at' => date('Y-m-d')],
]);
// Register extensions
Builder::macro('active', function() {
return $this->where('status', '=', 'active');
});
Builder::scope('recent', function($days = 7) {
$date = date('Y-m-d', strtotime("-{$days} days"));
return $this->where('created_at', '>=', $date);
});
// Chain everything
$users = $this->connection->table('users')
->active()
->withScope('recent', 30)
->whereByEmail('active@example.com')
->get();
expect($users)->toHaveCount(1)
->and($users[0]['name'])->toBe('Active User');
});Common Testing Pitfalls and Solutions
Date Range Issues: When testing date-based scopes, ensure your test data falls within the expected range:
php// Bad: May exclude test data if dates are old ->recent(365) // Good: Use a large enough range for test data ->recent(5000)Global Scope Order: When removing global scopes, call
withoutGlobalScope()beforewithGlobalScopes():php// Correct order $query = $connection->table('users') ->withoutGlobalScope('activeOnly') ->withGlobalScopes();Extension Cleanup: Always clear extensions in test setup/teardown to prevent interference:
phpbeforeEach(function () { Builder::clearMacros(); Builder::clearScopes(); Builder::clearFinders(); });Object Results: Bob returns stdClass objects by default for consistency:
php// Bob returns objects expect($user->email)->toBe('test@example.com'); // Both direct select and query builder return objects $user1 = $connection->table('users')->first(); // stdClass $user2 = $connection->selectOne('SELECT * FROM users'); // stdClass
Running Tests
# Run all tests
vendor/bin/pest
# Run specific test file
vendor/bin/pest tests/Feature/ExtensionSystemTest.php
# Run with filter
vendor/bin/pest --filter="can register and use macros"
# Run with coverage
vendor/bin/pest --coverageExample Complete Test File
<?php
use Bob\Database\Connection;
use Bob\Query\Builder;
beforeEach(function () {
$this->connection = new Connection([
'driver' => 'sqlite',
'database' => ':memory:',
]);
// Create test table
$this->connection->statement('
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
email TEXT,
status TEXT,
created_at TEXT
)
');
// Insert test data
$this->connection->table('users')->insert([
['name' => 'John Doe', 'email' => 'john@example.com', 'status' => 'active', 'created_at' => '2024-01-01'],
['name' => 'Jane Smith', 'email' => 'jane@example.com', 'status' => 'inactive', 'created_at' => '2024-01-02'],
]);
// Clear extensions
Builder::clearMacros();
Builder::clearScopes();
Builder::clearFinders();
});
afterEach(function () {
Builder::clearMacros();
Builder::clearScopes();
Builder::clearFinders();
});
test('my custom extension works', function () {
// Your test implementation
Builder::macro('whereActive', function() {
return $this->where('status', '=', 'active');
});
$activeUsers = $this->connection->table('users')
->whereActive()
->get();
expect($activeUsers)->toHaveCount(1)
->and($activeUsers[0]['name'])->toBe('John Doe');
});Best Practices
- Namespace Your Extensions: Use descriptive names to avoid conflicts
- Document Your Methods: Add PHPDoc comments for IDE support
- Test Thoroughly: Write tests for all custom functionality
- Version Carefully: Follow semantic versioning for your extensions
- Keep It Focused: Each extension package should have a single responsibility
- Avoid Global State: Use dependency injection where possible
- Performance: Be mindful of performance implications, especially with global scopes
Advanced Usage
Conditional Extensions
// Only register extensions in specific environments
if (defined('WP_ENV') && WP_ENV === 'development') {
Builder::macro('debugQuery', function() {
dd($this->toSql(), $this->getBindings());
});
}Extension Hooks
// Allow other developers to hook into your extension
Builder::macro('withHooks', function($hookName) {
// Allow WordPress-style filters
if (function_exists('apply_filters')) {
$this->wheres = apply_filters("bob_query_{$hookName}_wheres", $this->wheres);
}
return $this;
});Chain-able Extensions
// Ensure your extensions return $this for chaining
Builder::macro('cache', function($minutes = 60) {
$this->cacheMinutes = $minutes;
return $this; // Always return $this for chaining
});
// Usage
$posts = $connection->table('posts')
->cache(120)
->wherePublished()
->get();Using the Model Class
Bob provides a base Model class that combines the power of the query builder with ActiveRecord-style patterns. This allows you to define model-specific methods while still benefiting from global extensions.
Basic Model Setup
use Bob\Database\Model;
use Bob\Database\Connection;
// First, configure the connection for all models
$connection = new Connection([
'driver' => 'mysql',
'host' => 'localhost',
'database' => 'myapp',
'username' => 'root',
'password' => 'password',
]);
Model::setConnection($connection);Creating Your Own Model
<?php
namespace App\Models;
use Bob\Database\Model;
use Bob\Query\Builder;
class Post extends Model
{
protected string $table = 'posts';
protected string $primaryKey = 'id';
protected bool $timestamps = true;
/**
* Custom finder method - specific to Post model
* Usage: Post::findBySlug('my-awesome-post')
*/
public static function findBySlug(string $slug): ?self
{
$result = static::query()
->where('slug', $slug)
->first();
return $result ? static::hydrate($result) : null;
}
/**
* Find published posts - specific to Post model
* Usage: Post::findPublished(10)
*/
public static function findPublished(int $limit = 10): array
{
$results = static::query()
->where('status', 'published')
->where('published_at', '<=', date('Y-m-d H:i:s'))
->orderBy('published_at', 'desc')
->limit($limit)
->get();
return static::hydrateMany($results);
}
/**
* Scope for draft posts
* Usage: Post::draft()->get()
*/
public function scopeDraft(Builder $query): void
{
$query->where('status', 'draft');
}
/**
* Scope for featured posts
* Usage: Post::featured()->limit(5)->get()
*/
public function scopeFeatured(Builder $query): void
{
$query->where('is_featured', true);
}
/**
* Instance method to publish a post
*/
public function publish(): bool
{
$this->status = 'published';
$this->published_at = date('Y-m-d H:i:s');
return $this->save();
}
}
class User extends Model
{
protected string $table = 'users';
/**
* Find user by email - specific to User model
* Usage: User::findByEmail('user@example.com')
*/
public static function findByEmail(string $email): ?self
{
$result = static::query()
->where('email', $email)
->first();
return $result ? static::hydrate($result) : null;
}
/**
* Scope for active users
* Usage: User::active()->get()
*/
public function scopeActive(Builder $query): void
{
$query->where('status', 'active')
->whereNotNull('email_verified_at');
}
}Using Model-Specific Methods
// Model-specific methods are only available on that model
$post = Post::findBySlug('hello-world'); // ✅ Works
$publishedPosts = Post::findPublished(5); // ✅ Works
$user = User::findByEmail('john@example.com'); // ✅ Works
// These won't work - methods are model-specific
$user = User::findBySlug('john'); // ❌ Error - User doesn't have findBySlug
$posts = User::findPublished(); // ❌ Error - User doesn't have findPublishedCombining Model-Specific Methods with Global Extensions
// First, add a global macro (affects ALL models and queries)
Builder::macro('whereRecent', function($days = 7) {
$date = date('Y-m-d', strtotime("-{$days} days"));
return $this->where('created_at', '>=', $date);
});
// Now you can use both model-specific and global methods
$recentPublished = Post::query()
->whereRecent(30) // Global macro - works on any model
->where('status', 'published') // Standard query builder
->get();
$recentUsers = User::query()
->whereRecent(7) // Same global macro works here too
->get();
// Use model-specific scope with global macro
$featuredRecent = Post::featured() // Model-specific scope
->whereRecent(14) // Global macro
->limit(5)
->get();Model CRUD Operations
// Create
$post = Post::create([
'title' => 'My New Post',
'slug' => 'my-new-post',
'content' => 'Post content here...',
'status' => 'draft',
]);
// Read
$post = Post::find(1);
$post = Post::findOrFail(1); // Throws exception if not found
$allPosts = Post::all();
// Update
$post = Post::find(1);
$post->title = 'Updated Title';
$post->save();
// Delete
$post = Post::find(1);
$post->delete();
// Using query builder methods on models
$posts = Post::where('status', 'published')
->orderBy('created_at', 'desc')
->limit(10)
->get();Best Practices for Model Methods
Model-Specific Methods: Define methods that are specific to the model's domain
phpclass Product extends Model { public static function findInStock(): array { /* ... */ } public static function findByCategory(string $category): array { /* ... */ } }Use Scopes for Reusable Query Logic: Scopes are better for chainable query modifiers
phppublic function scopeActive(Builder $query): void { $query->where('active', true); } // Usage: Product::active()->inStock()->get()Global Extensions for Cross-Model Features: Use macros for functionality needed across all models
phpBuilder::macro('whereToday', function() { return $this->whereDate('created_at', date('Y-m-d')); }); // Works on any model: User::whereToday()->get(), Post::whereToday()->get()
WordPress Integration with Models
For WordPress projects, you can create a base WordPress model:
namespace App\Models;
use Bob\Database\Model;
abstract class WordPressModel extends Model
{
/**
* Get WordPress table prefix
*/
protected function getTable(): string
{
global $wpdb;
$table = parent::getTable();
return $wpdb->prefix . $table;
}
}
class WPPost extends WordPressModel
{
protected string $table = 'posts'; // Will become wp_posts
public static function findBySlug(string $slug): ?self
{
$result = static::query()
->where('post_name', $slug)
->where('post_status', 'publish')
->first();
return $result ? static::hydrate($result) : null;
}
public function scopePublished(Builder $query): void
{
$query->where('post_status', 'publish')
->where('post_type', 'post');
}
}Conclusion
Bob Query Builder's extension system makes it incredibly flexible and adaptable to any domain or framework. Whether you're building for WordPress, Laravel, or a custom application, you can extend Bob to fit your specific needs without modifying the core library.
The combination of:
- Global Macros - for cross-cutting functionality
- Model-Specific Methods - for domain logic
- Query Scopes - for reusable query patterns
- Dynamic Finders - for intuitive method names
Gives you complete control over how you interact with your database while maintaining clean, readable code.
For more examples and the latest updates, visit the Bob Query Builder GitHub repository.