How to design a modular Active Record Wrapper architecture for enterprise-level custom plugins
Core Principles of a Modular Active Record Wrapper
When developing custom plugins for enterprise-level WordPress applications, a robust data abstraction layer is paramount. This layer, often built around an Active Record pattern, simplifies database interactions, enhances maintainability, and promotes code reusability. For true modularity, we must decouple the data access logic from the business logic and presentation layers. This involves creating distinct components for model definitions, repository management, and query building, allowing for easier testing, swapping of implementations, and integration with external systems.
Defining the Base Model
The foundation of our modular architecture is a base model class. This class will encapsulate common Active Record functionalities such as attribute hydration, saving, deleting, and retrieving by ID. It will also define the contract for all derived models, ensuring consistency.
Let’s define a skeletal `BaseModel` in PHP. This example assumes a direct database interaction for simplicity, but in a production system, this would delegate to a repository.
namespace MyPlugin\Data\Models;
abstract class BaseModel {
protected static string $table;
protected array $attributes = [];
protected array $originalAttributes = [];
protected bool $exists = false;
public function __construct(array $attributes = []) {
$this->fill($attributes);
$this->originalAttributes = $this->attributes;
}
public function fill(array $attributes): void {
foreach ($attributes as $key => $value) {
$this->setAttribute($key, $value);
}
}
public function setAttribute(string $key, mixed $value): void {
$this->attributes[$key] = $value;
}
public function getAttribute(string $key): mixed {
return $this->attributes[$key] ?? null;
}
public function __get(string $key): mixed {
return $this->getAttribute($key);
}
public function __set(string $key, mixed $value): void {
$this->setAttribute($key, $value);
}
public function save(): bool {
if (empty(static::$table)) {
throw new \LogicException("Table name is not defined for model " . get_class($this));
}
// In a real scenario, this would call a repository method.
// For demonstration, we'll simulate DB operations.
if ($this->exists) {
return $this->update();
} else {
return $this->create();
}
}
protected function create(): bool {
// Simulate database insert
$dataToInsert = array_diff_assoc($this->attributes, $this->originalAttributes);
if (empty($dataToInsert)) return true; // No changes
// Simulate ID generation
$newId = rand(1000, 9999); // Placeholder for actual DB ID
$this->attributes['id'] = $newId;
$this->originalAttributes = $this->attributes;
$this->exists = true;
// echo "Simulating INSERT into " . static::$table . ": " . json_encode($dataToInsert) . "\n";
return true;
}
protected function update(): bool {
// Simulate database update
$dataToUpdate = array_diff_assoc($this->attributes, $this->originalAttributes);
if (empty($dataToUpdate)) return true; // No changes
// echo "Simulating UPDATE on " . static::$table . " (ID: " . $this->id . "): " . json_encode($dataToUpdate) . "\n";
$this->originalAttributes = $this->attributes;
return true;
}
public function delete(): bool {
if (!$this->exists || empty(static::$table)) {
return false;
}
// Simulate database delete
// echo "Simulating DELETE from " . static::$table . " (ID: " . $this->id . ")\n";
$this->exists = false;
return true;
}
public static function find(int $id): ?static {
if (empty(static::$table)) {
throw new \LogicException("Table name is not defined for model " . static::class);
}
// Simulate database fetch by ID
// echo "Simulating SELECT from " . static::$table . " WHERE id = " . $id . "\n";
$mockData = ['id' => $id, 'name' => 'Sample ' . static::class, 'created_at' => date('Y-m-d H:i:s')]; // Mock data
if (empty($mockData)) {
return null;
}
$model = new static($mockData);
$model->exists = true;
$model->originalAttributes = $model->attributes;
return $model;
}
public static function all(): array {
if (empty(static::$table)) {
throw new \LogicException("Table name is not defined for model " . static::class);
}
// Simulate fetching all records
// echo "Simulating SELECT * from " . static::$table . "\n";
$mockData = [
['id' => 1, 'name' => 'Item A', 'created_at' => date('Y-m-d H:i:s')],
['id' => 2, 'name' => 'Item B', 'created_at' => date('Y-m-d H:i:s')],
]; // Mock data
$models = [];
foreach ($mockData as $data) {
$model = new static($data);
$model->exists = true;
$model->originalAttributes = $model->attributes;
$models[] = $model;
}
return $models;
}
public function isNewRecord(): bool {
return !$this->exists;
}
public function isDirty(string $attribute = null): bool {
if ($attribute) {
return isset($this->attributes[$attribute]) && $this->attributes[$attribute] !== ($this->originalAttributes[$attribute] ?? null);
}
return $this->attributes !== $this->originalAttributes;
}
public function getAttributes(): array {
return $this->attributes;
}
public function getOriginalAttributes(): array {
return $this->originalAttributes;
}
}
Implementing Specific Models
Concrete models will extend `BaseModel` and define their specific table name and any model-specific logic. This keeps the base class clean and focused on generic operations.
For example, a `Product` model:
namespace MyPlugin\Data\Models;
class Product extends BaseModel {
protected static string $table = 'myplugin_products';
// Add product-specific methods or attribute accessors if needed
public function getFormattedPrice(): string {
return '$' . number_format($this->price, 2);
}
}
Introducing the Repository Pattern
To achieve true modularity and testability, we must abstract the actual data persistence logic away from the models. This is where the Repository pattern shines. A repository acts as a mediator between the domain models and data mapping layers. It handles the retrieval and storage of domain objects, abstracting away the complexities of the underlying data source.
Repository Interface
Define an interface for each model’s repository. This allows for easy swapping of implementations (e.g., from a direct DB connection to an API or a different ORM).
namespace MyPlugin\Data\Repositories;
use MyPlugin\Data\Models\BaseModel;
use MyPlugin\Data\Models\Product; // Example
interface ProductRepositoryInterface {
public function findById(int $id): ?Product;
public function findAll(): array;
public function save(Product $product): bool;
public function delete(Product $product): bool;
public function findBySku(string $sku): ?Product; // Example custom method
}
Concrete Repository Implementation
Implement the interface. This implementation will contain the actual database query logic. For enterprise plugins, consider using a robust database abstraction library or even a full ORM like Eloquent (if not already using WordPress’s internal methods extensively).
Here’s a simplified example using WordPress’s `$wpdb` global. In a real-world scenario, you’d inject `$wpdb` or a wrapper around it.
namespace MyPlugin\Data\Repositories;
use MyPlugin\Data\Models\Product;
use wpdb; // Assuming $wpdb is available globally or injected
class WpdbProductRepository implements ProductRepositoryInterface {
private wpdb $wpdb;
private string $tableName;
public function __construct(wpdb $wpdb = null) {
global $wpdb;
$this->wpdb = $wpdb ?? $wpdb;
$this->tableName = $this->wpdb->prefix . Product::getTableName(); // Assuming BaseModel has getTableName()
}
// Helper to get table name from model
// Add this to BaseModel:
// public static function getTableName(): string { return static::$table; }
public function findById(int $id): ?Product {
$sql = $this->wpdb->prepare("SELECT * FROM {$this->tableName} WHERE id = %d", $id);
$result = $this->wpdb->get_row($sql, ARRAY_A);
if (!$result) {
return null;
}
return $this->hydrateProduct($result);
}
public function findAll(): array {
$sql = "SELECT * FROM {$this->tableName}";
$results = $this->wpdb->get_results($sql, ARRAY_A);
$products = [];
foreach ($results as $row) {
$products[] = $this->hydrateProduct($row);
}
return $products;
}
public function findBySku(string $sku): ?Product {
$sql = $this->wpdb->prepare("SELECT * FROM {$this->tableName} WHERE sku = %s", $sku);
$result = $this->wpdb->get_row($sql, ARRAY_A);
if (!$result) {
return null;
}
return $this->hydrateProduct($result);
}
public function save(Product $product): bool {
$data = $product->getAttributes();
$data = array_filter($data, function($value) { return $value !== null; }); // Remove null values
if ($product->isNewRecord()) {
// Insert
$inserted = $this->wpdb->insert($this->tableName, $data);
if ($inserted) {
$product->id = $this->wpdb->insert_id;
$product->exists = true;
$product->originalAttributes = $product->attributes; // Update original attributes after save
return true;
}
} else {
// Update
$where = ['id' => $product->id];
$updated = $this->wpdb->update($this->tableName, $data, $where);
if ($updated !== false) { // update() returns false on error, 0 if no rows updated, >0 if rows updated
$product->originalAttributes = $product->attributes; // Update original attributes after save
return true;
}
}
return false;
}
public function delete(Product $product): bool {
if ($product->isNewRecord()) {
return false; // Cannot delete a new record
}
$deleted = $this->wpdb->delete($this->tableName, ['id' => $product->id]);
if ($deleted) {
$product->exists = false;
return true;
}
return false;
}
private function hydrateProduct(array $data): Product {
$product = new Product($data);
$product->exists = true;
$product->originalAttributes = $product->attributes;
return $product;
}
}
Dependency Injection and Service Container
To manage dependencies effectively and promote loose coupling, a dependency injection (DI) container or a simple service locator pattern is highly recommended. This allows you to register your repositories and models and retrieve them where needed without hardcoding their instantiation.
A basic service locator implementation:
namespace MyPlugin\Data;
use MyPlugin\Data\Repositories\ProductRepositoryInterface;
use MyPlugin\Data\Repositories\WpdbProductRepository;
use wpdb;
class ServiceContainer {
private static array $services = [];
public static function register(string $key, callable $factory): void {
self::$services[$key] = $factory;
}
public static function get(string $key): mixed {
if (!isset(self::$services[$key])) {
throw new \InvalidArgumentException("Service {$key} not registered.");
}
// Lazy loading: instantiate only when requested
if (!is_object(self::$services[$key]) || !self::$services[$key] instanceof \Closure) {
// If it's already an instance, return it. This is a simple caching mechanism.
return self::$services[$key];
}
// If it's a callable factory, call it and store the instance
self::$services[$key] = self::$services[$key]();
return self::$services[$key];
}
public static function init(): void {
// Registering services
self::register(ProductRepositoryInterface::class, function() {
global $wpdb;
return new WpdbProductRepository($wpdb);
});
// You could also register models if they have complex instantiation logic
// self::register('product_model', function() { return new Product(); });
}
}
Usage Example
With the service container set up, using the models and repositories becomes straightforward.
namespace MyPlugin\Controllers; // Or wherever your business logic resides
use MyPlugin\Data\ServiceContainer;
use MyPlugin\Data\Repositories\ProductRepositoryInterface;
use MyPlugin\Data\Models\Product;
class ProductController {
private ProductRepositoryInterface $productRepository;
public function __construct() {
// Initialize the service container on plugin load or in an appropriate hook
// ServiceContainer::init(); // This should be called once, e.g., in your main plugin file
// Get the repository from the service container
$this->productRepository = ServiceContainer::get(ProductRepositoryInterface::class);
}
public function displayProduct(int $productId): void {
$product = $this->productRepository->findById($productId);
if ($product) {
echo "" . esc_html($product->name) . "
";
echo "<p>Price: " . esc_html($product->getFormattedPrice()) . "</p>";
// ... more display logic
} else {
echo "<p>Product not found.</p>";
}
}
public function createNewProduct(array $data): ?Product {
$newProduct = new Product($data); // Instantiate model directly
// $newProduct->name = $data['name']; // Or set attributes
// $newProduct->price = $data['price'];
if ($this->productRepository->save($newProduct)) {
return $newProduct;
}
return null;
}
public function updateProduct(int $productId, array $newData): bool {
$product = $this->productRepository->findById($productId);
if (!$product) {
return false;
}
$product->fill($newData); // Use fill to update multiple attributes
if ($product->isDirty()) { // Check if any changes were made
return $this->productRepository->save($product);
}
return true; // No changes, considered successful
}
}
// --- Initialization Example (in your main plugin file) ---
// add_action('plugins_loaded', function() {
// MyPlugin\Data\ServiceContainer::init();
// });
// --- Usage Example ---
// $controller = new ProductController();
// $controller->displayProduct(123);
// $createdProduct = $controller->createNewProduct(['name' => 'New Gadget', 'price' => 99.99]);
// if ($createdProduct) {
// // ...
// }
// $controller->updateProduct(123, ['price' => 105.50]);
Advanced Considerations for Enterprise Plugins
- Database Abstraction: For complex plugins, abstracting `$wpdb` further into a dedicated database connection manager or using a lightweight ORM can provide more features like query builders, migrations, and schema management.
- Caching: Integrate a caching layer (e.g., object cache, transient API) within the repository to reduce database load for frequently accessed, rarely changing data.
- Validation: Implement validation logic within the model or a dedicated validator class before saving data. This ensures data integrity.
- Event System: Hook into model events (e.g., `before_save`, `after_save`, `before_delete`) to trigger custom logic or integrate with other systems.
- Testing: The repository pattern, combined with DI, makes unit testing significantly easier. You can mock repository interfaces to test business logic without touching the actual database.
- Error Handling: Implement robust error handling and logging mechanisms for database operations.
- Transactions: For operations involving multiple database writes, wrap them in database transactions to ensure atomicity. The repository implementation would manage transaction boundaries.
Conclusion
This modular Active Record wrapper architecture provides a scalable and maintainable foundation for enterprise-level WordPress plugins. By separating concerns into models, repositories, and a service container, you create a system that is easier to develop, test, and extend. This approach aligns with best practices in software engineering and ensures your plugin can adapt to future requirements and complexities.