Repository Pattern in WordPress Plugins: Complete Developer Guide
Introduction
As a WordPress plugin grows, database access can quickly become difficult to manage.
A small plugin may start with a simple query:
global $wpdb; $order = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}orders WHERE id = %d", $orderId ), ARRAY_A );
There is nothing inherently wrong with this approach.
The problem appears when the same type of query is needed in several places.
A REST controller may access the database directly.
An admin page may contain another query.
A cron job may use its own SQL.
A service may update the same table.
Soon, persistence logic becomes scattered across the application.
The Repository Pattern provides a way to create a clear boundary around data access.
Instead of allowing every component to understand SQL or WordPress persistence APIs, application code can work with methods such as:
$order = $repository->find($orderId);
The architecture becomes:
REST / Admin / Cron / CLI ↓ Service ↓ Repository ↓ WordPress API / $wpdb ↓ Storage
This separation can improve maintainability, testability, consistency, and future refactoring.
In this guide, you'll learn what the Repository Pattern is, when to use it in WordPress plugins, how to design repository interfaces, how to work with $wpdb, WordPress posts and metadata, WooCommerce, custom tables, caching, pagination, testing, dependency injection, security, and common repository mistakes.
What Is the Repository Pattern?
The Repository Pattern creates an abstraction between application code and data storage.
Instead of exposing database details to services, a repository provides application-specific data operations.
For example:
$order = $orderRepository->find(123);
The service does not need to know whether the data comes from:
$wpdb
WordPress posts
Metadata
WooCommerce APIs
A custom table
Another storage implementation
The repository owns that decision.
Why Use Repositories in WordPress?
Repositories are useful when data access becomes:
Complex
Repeated
Shared by multiple services
Important to test
Tied to custom tables
Difficult to maintain in controllers
They can provide:
Centralized persistence logic
All related data access remains in one place.
Cleaner services
Services focus on business rules instead of SQL.
Better testing
Repositories can be mocked or replaced with fakes.
Consistent queries
The same filters and rules can be applied consistently.
Repository vs Service
These two layers should have different responsibilities.
Service
Answers:
What should the application do?
For example:
Process Order Sync Customer Generate Report
Repository
Answers:
How do I retrieve or persist the required data?
For example:
Find Order Save Order Find Orders by Customer Update Order Status
The relationship is:
Application Service ↓ Repository ↓ Data Storage
Repository vs Direct $wpdb Usage
Direct database access:
Controller ↓ $wpdb ↓ SQL
Repository-based architecture:
Controller ↓ Service ↓ Repository ↓ $wpdb
The second design adds a layer, but it keeps SQL and persistence decisions localized.
Should Every WordPress Plugin Use Repositories?
No.
A small plugin with one or two simple persistence operations may not need a repository.
For example:
$value = \get_option( 'myplugin_enabled', false );
Creating a large repository architecture around a single option may be unnecessary.
Repositories become more valuable when the persistence layer has meaningful complexity.
Define a Repository Interface
For larger plugins, define a contract:
namespace Kaddora\MyPlugin\Contracts; interface OrderRepositoryInterface { public function find(int $id): ?array; public function findByCustomer( int $customerId ): array; public function create(array $data): int; public function update( int $id, array $data ): bool; }
This describes the data boundary without revealing implementation details.
Implement the Repository
The implementation can use $wpdb:
namespace Kaddora\MyPlugin\Repositories; use Kaddora\MyPlugin\Contracts\OrderRepositoryInterface; final class OrderRepository implements OrderRepositoryInterface { public function find(int $id): ?array { global $wpdb; $table = $wpdb->prefix . 'my_orders'; $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A ); return $row ?: null; } }
Now the service doesn't need to know anything about the table.
Always Use Prepared Queries
Dynamic SQL values should be passed using appropriate WordPress database APIs.
For example:
$query = $wpdb->prepare( "SELECT * FROM {$table} WHERE customer_id = %d", $customerId );
Avoid:
$sql = "SELECT * FROM {$table} WHERE customer_id = {$customerId}";
Prepared queries reduce the risk of SQL injection caused by unsafe value interpolation.
Trusted SQL identifiers such as table names must still be constructed from controlled values.
Repository Methods Should Express Intent
Prefer:
find() findByCustomer() findByStatus() create() update() delete()
over:
query() run() execute() getData()
A repository should expose meaningful operations rather than raw database mechanics.
Don't Expose Raw SQL
Avoid creating:
public function query( string $sql ): array { // ... }
This does not provide a useful abstraction.
Instead, expose domain-relevant methods:
public function findByStatus( string $status ): array { // ... }
The caller shouldn't need to understand the database structure.
Repository Return Types
Repositories can return:
Arrays
DTOs
Domain objects
WordPress objects
null
Typed result objects
For example:
public function find( int $id ): ?OrderData
or:
public function find( int $id ): ?array
Choose a consistent model based on plugin complexity.
Arrays vs DTOs vs Domain Objects
For simpler plugins:
[ 'id' => 123, 'status' => 'completed', ]
may be enough.
For complex applications:
final class OrderData { public function __construct( public readonly int $id, public readonly string $status ) { } }
provides stronger structure.
Don't introduce rich domain objects unless they solve a real problem.
Repository for WordPress Posts
A repository does not need to use SQL directly.
For post data:
final class ArticleRepository { public function find( int $id ): ?\WP_Post { $post = \get_post($id); return $post instanceof \WP_Post ? $post : null; } }
The application still receives a clean data-access boundary.
Repository for Metadata
Metadata can also be encapsulated:
public function getSettings( int $postId ): array { return [ 'enabled' => (bool) \get_post_meta( $postId, '_my_enabled', true ), 'mode' => (string) \get_post_meta( $postId, '_my_mode', true ), ]; }
The service doesn't need to know the individual meta keys.
Repository for Users
A user repository can wrap WordPress APIs:
final class CustomerRepository { public function find( int $userId ): ?\WP_User { $user = \get_user_by( 'id', $userId ); return $user instanceof \WP_User ? $user : null; } }
This is useful when user access becomes more complex.
Repository for Options
A settings repository can centralize plugin configuration:
final class SettingsRepository { public function get( string $key, mixed $default = null ): mixed { return \get_option( $key, $default ); } public function save( string $key, mixed $value ): bool { return \update_option( $key, $value ); } }
For large settings systems, this can keep configuration access consistent.
Repository for Custom Tables
Custom tables are often where repositories provide the greatest architectural benefit.
For example:
wp_kdr_orders wp_kdr_logs wp_kdr_usage wp_kdr_events
A repository can isolate each data boundary:
OrderRepository LogRepository UsageRepository EventRepository
Each repository owns queries for its associated data.
Repository for WooCommerce
WooCommerce already provides APIs and data abstractions for many entities.
A repository can wrap those APIs:
OrderRepository ↓ WooCommerce Order API ↓ WooCommerce Data Layer
Use WooCommerce's established APIs when they provide the behavior you need rather than bypassing them with direct SQL unnecessarily.
WooCommerce Custom Data
For plugin-specific WooCommerce data, a custom repository can use a dedicated table:
WooCommerce ↓ Custom Extension ↓ Custom Repository ↓ Custom Table
This keeps extension-specific persistence separate from WooCommerce's core data model.
Repository and Service Example
Consider a customer-sync feature.
The repository provides:
public function findUnsynced(): array { // Retrieve customers that require synchronization. }
The service handles:
CustomerSyncService ↓ Find Unsynced Customers ↓ Transform Data ↓ Send to CRM ↓ Mark Synchronized
The repository only handles data access.
Repository and Dependency Injection
Inject repositories into services:
final class CustomerSyncService { public function __construct( private CustomerRepositoryInterface $repository ) { } }
This makes the persistence dependency explicit.
It also makes service-level unit testing easier.
Repository and Multiple Implementations
Interfaces are especially useful when multiple storage implementations exist:
CustomerRepositoryInterface ↓ ┌──────┴──────┐ ↓ ↓ WordPress Remote Repository Repository
The service doesn't change.
Only the implementation changes.
Repository and Testing
A fake repository can be used in unit tests:
final class InMemoryOrderRepository implements OrderRepositoryInterface { public function __construct( private array $orders = [] ) { } public function find(int $id): ?array { return $this->orders[$id] ?? null; } }
Now:
$service = new OrderService( new InMemoryOrderRepository() );
No database is required for that test.
Mocking Repositories
PHPUnit can also mock the interface:
$repository = $this->createMock( OrderRepositoryInterface::class ); $repository ->method('find') ->willReturn([ 'id' => 10, 'status' => 'pending', ]);
This allows focused service tests.
Repository Integration Tests
Unit testing a service doesn't prove the SQL works.
Repository integration tests should verify real persistence:
Repository ↓ WordPress Test Environment ↓ Database
Test:
Insert
Read
Update
Delete
Filters
Pagination
Constraints
Empty results
Error handling
Repository and Pagination
Avoid methods that accidentally load huge result sets.
Instead:
public function paginate( int $page, int $perPage ): array { $offset = max( 0, ($page - 1) * $perPage ); // Query with controlled limits. return []; }
For very large datasets, keyset or cursor-based pagination may be more efficient than very large offsets.
Repository Filtering
A controlled filter API can be useful:
$orders = $repository->search([ 'status' => 'completed', 'customer_id' => 42, ]);
The repository should accept only supported filters.
Never allow callers to provide arbitrary SQL fragments.
Repository Sorting
Sort options should be allowlisted.
For example:
$allowedOrderBy = [ 'created_at', 'amount', 'status', ];
This prevents user-controlled values from becoming arbitrary SQL identifiers.
The sort direction should also be validated:
ASC DESC
Repository and Database Indexes
Repository performance depends on database design.
If a repository frequently queries:
WHERE customer_id = ?
an appropriate index may be useful.
For:
WHERE customer_id = ? AND status = ?
a composite index may sometimes be appropriate.
Indexes should be designed from actual query patterns, not added blindly.
Repository and N+1 Queries
Repositories can still produce inefficient access patterns.
For example:
Load 100 Orders ↓ For each Order ↓ Load Customer ↓ 100+ Queries
A better repository API might load related records in batches.
The abstraction doesn't remove the need for query profiling.
Repository and Batch Operations
Large plugins may benefit from:
public function updateStatuses( array $ids, string $status ): int { // Controlled batch update. return 0; }
Batch operations can reduce repetitive queries.
Validate IDs and supported status values carefully.
Repository and Caching
A repository can sometimes act as a natural cache boundary:
Service ↓ Cached Repository ↓ Database Repository ↓ Database
On cache hit:
Cache → Return
On cache miss:
Cache ↓ Repository ↓ Database ↓ Cache
Caching should be based on actual workload and clear invalidation rules.
Repository and Soft Deletes
Some applications shouldn't permanently delete records.
A repository can expose:
delete() restore() find() findIncludingDeleted()
while internally using a deleted_at field.
This can be useful for systems where history must be preserved.
Repository and Audit Trails
For important actions, audit logging may be required.
Avoid hiding unrelated side effects inside every repository method.
A cleaner architecture may be:
OrderService ├── OrderRepository └── AuditLogger
The service coordinates both responsibilities.
Repository and Transactions
Some application workflows require multiple database operations to succeed together.
For example:
Create Order ↓ Create Payment Record ↓ Write Audit Record
Transaction ownership should be explicit.
Don't automatically start and commit a transaction inside every repository method, because larger workflows may need several repository operations to share one transaction boundary.
Repository and WordPress APIs
Repositories don't need to bypass WordPress abstractions.
For posts:
\get_post()
For users:
\get_user_by()
For options:
\get_option()
For metadata:
\get_post_meta()
For WooCommerce:
WooCommerce APIs
The repository simply creates an application-specific boundary.
Repository and External APIs
An external API isn't automatically a repository.
For example:
CustomerRepository ↓ Local Customer Data
while:
CRMClient ↓ External CRM API
is usually better modeled as an integration.
If the application explicitly treats the external system as its persistence source, a repository abstraction can still be appropriate.
Repository and Adapter Pattern
Repositories and adapters can work together:
Service ↓ Repository ↓ External Data Adapter ↓ External API
The adapter translates the provider's interface.
The repository provides the application's data-access contract.
Repository and Factory Pattern
A factory can select a repository implementation:
Repository Factory ↓ Repository Interface ┌────┴────┐ ↓ ↓ WP Store Remote Store
This is useful only when persistence implementations genuinely vary.
Repository and Strategy Pattern
A repository normally handles persistence, while Strategy handles interchangeable algorithms.
For example:
RecommendationService ↓ RecommendationStrategy ↓ ProductRepository
The strategy requests data from the repository.
This keeps algorithm and persistence concerns separate.
Repository and Service Container
A DI container can bind:
OrderRepositoryInterface ↓ OrderRepository
Then:
OrderService ↓ OrderRepositoryInterface
The service remains independent of the concrete repository.
Repository and Namespaces
A typical namespace structure is:
Kaddora\MyPlugin\Repositories Kaddora\MyPlugin\Contracts Kaddora\MyPlugin\Services
For example:
Kaddora\MyPlugin\Repositories\OrderRepository
can map through Composer PSR-4 to:
src/Repositories/OrderRepository.php
Recommended Repository Directory Structure
src/ ├── Contracts/ │ ├── OrderRepositoryInterface.php │ └── ProductRepositoryInterface.php ├── Repositories/ │ ├── OrderRepository.php │ └── ProductRepository.php ├── Services/ ├── Integrations/ ├── Admin/ └── Rest/
For very large plugins, repositories can instead be organized within domain modules.
Repository and Type Safety
Use explicit parameter and return types:
public function find( int $id ): ?array
rather than:
public function find($id)
Strong types make repositories easier to document and analyze.
Repository Error Handling
Distinguish between:
Record Not Found
and:
Database Failure
For example:
find() → null
may mean the record doesn't exist.
A database exception or explicit error may indicate that persistence itself failed.
Don't silently convert all failures into "not found."
Repository Security
Repositories should use:
Prepared SQL
Allowlisted sort fields
Validated filters
Controlled table names
Appropriate data access
Safe error messages
But repositories do not replace authorization.
A secure request flow remains:
Authentication ↓ Authorization ↓ Validation ↓ Service ↓ Repository
Repository Performance
Repositories can hide expensive queries.
Always inspect:
Query count
Query execution time
Result size
Index usage
N+1 behavior
Repeated queries
A clean repository API is not automatically an efficient one.
Repository and Database Schema
Repository design should reflect the actual schema.
For a custom table:
CREATE TABLE wp_my_orders ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, customer_id BIGINT UNSIGNED NOT NULL, status VARCHAR(50) NOT NULL, amount DECIMAL(18,2) NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY customer_id (customer_id), KEY status (status) );
The repository should work consistently with those fields and constraints.
Repository Maintenance
When database schema changes, update:
Migration ↓ Repository ↓ DTO / Domain Model ↓ Service ↓ Tests
Schema changes should not silently break repository assumptions.
Repository Testing Strategy
Unit Tests
Use mocks or fakes for services.
Integration Tests
Verify repository behavior against the real database.
Performance Tests
Profile large queries and datasets where required.
Compatibility Tests
Run against supported PHP, WordPress, and WooCommerce environments.
How to Build a WordPress Repository Step by Step
Step 1: Identify the Data Boundary
Choose an entity such as:
Order Product Customer Subscription Report
Step 2: Identify Required Operations
For example:
find() findByStatus() create() update() delete()
Step 3: Create an Interface
Use one when abstraction provides real value.
Step 4: Choose the Persistence API
Use:
WordPress APIs WooCommerce APIs $wpdb Custom Tables
Step 5: Implement the Repository
Keep persistence logic inside the repository.
Step 6: Add Type Safety
Define clear parameters and return types.
Step 7: Add Validation
Validate filters and supported values.
Step 8: Inject Into Services
Use constructor dependency injection.
Step 9: Add Tests
Test both application behavior and real persistence.
Step 10: Profile Performance
Look for slow queries and N+1 access patterns.
Example Repository + Service Architecture
WordPress │ ┌─────────┼─────────┐ ↓ ↓ ↓ REST Admin Cron │ │ │ └─────────┼─────────┘ ↓ Service ↓ Repository ↓ ┌──────────┴──────────┐ ↓ ↓ WordPress API $wpdb ↓ ↓ Core Data Custom Tables
The application layer remains independent of low-level storage details.
Example: Order Repository
final class OrderRepository { public function find( int $orderId ): ?array { global $wpdb; $table = $wpdb->prefix . 'my_orders'; $row = $wpdb->get_row( $wpdb->prepare( "SELECT id, customer_id, status, amount FROM {$table} WHERE id = %d", $orderId ), ARRAY_A ); return $row ?: null; } public function updateStatus( int $orderId, string $status ): bool { global $wpdb; $table = $wpdb->prefix . 'my_orders'; $result = $wpdb->update( $table, [ 'status' => $status ], [ 'id' => $orderId ], [ '%s' ], [ '%d' ] ); return $result !== false; } }
The repository owns the persistence operations.
Example: Order Service
final class OrderService { public function __construct( private OrderRepository $repository ) { } public function complete( int $orderId ): bool { $order = $this->repository->find( $orderId ); if ($order === null) { return false; } if ( $order['status'] === 'completed' ) { return true; } return $this->repository->updateStatus( $orderId, 'completed' ); } }
The service contains application behavior.
The repository contains persistence behavior.
Repository Pattern for AI WordPress Plugins
AI plugins often store:
Conversations
Prompts
Usage records
Generated content
Provider settings
Logs
Repositories can isolate this persistence:
AIService ↓ ConversationRepository ↓ UsageRepository ↓ Database
Provider API calls should remain in integration classes rather than being mixed into repository logic.
Repository Pattern for Analytics Plugins
An analytics plugin may have:
EventRepository ReportRepository UsageRepository
A reporting service can then combine them:
ReportService ↓ ReportRepository ↓ Analytics Data
For large datasets, pagination, indexes, aggregation queries, and caching become especially important.
Repository Pattern for SaaS Plugins
SaaS-oriented plugins may manage:
Tenants Users Subscriptions Invoices Usage Integrations
Repositories can create clear persistence boundaries:
TenantRepository SubscriptionRepository InvoiceRepository UsageRepository
Multi-tenant filtering should be enforced consistently and tested thoroughly.
Common Repository Mistakes
Avoid:
Putting business logic in repositories
Exposing raw SQL
Accepting arbitrary SQL
Returning uncontrolled result sets
Ignoring N+1 queries
Skipping indexes
Mixing persistence with API calls
Hiding unrelated side effects
Creating repositories for trivial operations
Returning inconsistent data structures
A repository should make data access clearer, not merely move SQL to another file.
Repository Pattern Checklist
Architecture
Clear data boundary
Focused repository responsibility
Business logic remains in services
Interfaces used where valuable
Database
Prepared queries
Validated filters
Controlled sorting
Appropriate indexes
Pagination for large datasets
WordPress
Native APIs used where appropriate
WooCommerce APIs respected
$wpdb isolated appropriately
Testing
Unit tests for services
Repository integration tests
Error cases tested
Large-data behavior considered
Security
No arbitrary SQL
Authorization handled at proper boundaries
Sensitive data protected
Safe error messages
Using AI to Design Repository Architecture
AI can help developers analyze existing WordPress code:
Existing SQL Queries ↓ Group by Entity ↓ Find Repeated Access ↓ Suggest Repository Boundaries ↓ Generate Tests ↓ Developer Review
AI can identify:
Duplicate SQL
Repeated queries
Candidate repository methods
Potential N+1 patterns
Missing indexes
Inconsistent data access
Opportunities for pagination
However, generated database code must be reviewed carefully.
AI may misunderstand:
WordPress data models
WooCommerce relationships
Custom schemas
Business retention requirements
Transactions
Multisite context
Use AI as an assistant rather than blindly accepting generated persistence logic.
Why Choose ThemeKaddora?
ThemeKaddora develops WordPress themes, plugins, WooCommerce solutions, AI products, HTML templates, UI kits, SaaS-oriented solutions, and business-focused digital products.
For complex products, the Repository Pattern can provide a clear persistence boundary:
ThemeKaddora Product ↓ Application Service ↓ Repository ↓ WordPress / WooCommerce / Custom Database
It can support:
Custom database tables
WooCommerce data access
Analytics records
AI usage tracking
Subscription data
API synchronization state
Reporting
Caching
Testing
Static analysis
When combined with:
Namespaces
Composer
PSR-4
Dependency Injection
Service Containers
Strategy
Adapter
PHPUnit
PHPStan
PHPCS
CI/CD
repositories can become an important part of a scalable plugin architecture.
The key is to use them where persistence complexity justifies a dedicated abstraction.
Final Thoughts
The Repository Pattern is a practical way to separate application behavior from data access in WordPress plugins.
The core relationship is:
Controller / Hook ↓ Service ↓ Repository ↓ Storage
The service asks:
What should the application do?
The repository answers:
How should the required data be retrieved or persisted?
A strong repository should:
Represent a clear data boundary
Expose meaningful methods
Keep SQL or persistence APIs localized
Use prepared queries
Control filters and sorting
Support pagination
Consider indexing and query performance
Distinguish missing data from infrastructure failures
Remain free of unrelated side effects
Be easy to test
Repositories can wrap many WordPress data sources:
WordPress Posts WordPress Users Metadata Options WooCommerce Custom Tables
They can also work alongside:
Services Dependency Injection Factories Adapters Strategies Service Containers
For small plugins, a repository may be unnecessary.
For large WordPress products, WooCommerce extensions, analytics systems, AI plugins, automation platforms, and SaaS solutions, centralized persistence logic can dramatically improve maintainability.
The most important principle is:
Don't create repositories simply to hide SQL. Create them to establish a meaningful data-access boundary.
A good repository architecture prevents database details from spreading into controllers, REST endpoints, hooks, cron jobs, and business services.
The practical workflow is:
Identify the data boundary → define operations → isolate persistence → inject the repository → test the service → integration-test the database → profile real queries.
Keep business rules in services.
Keep persistence logic in repositories.
Keep external API translation in adapters.
Keep interchangeable algorithms in strategies.
Keep object construction in factories or composition infrastructure.
That separation creates a cleaner architecture that can evolve as the plugin grows.
For ThemeKaddora products, this becomes particularly valuable when a simple WordPress plugin evolves into a larger WooCommerce, AI, analytics, automation, or SaaS-oriented application with increasingly complex data requirements.
The goal isn't to build the most abstract persistence layer.
The goal is to make data access predictable, testable, secure, and maintainable.
Frequently Asked Questions
What is the Repository Pattern in WordPress?
The Repository Pattern creates a layer between application code and data storage, allowing services to retrieve and persist data without directly handling database details.
Why use repositories in WordPress plugins?
Repositories can centralize data access, reduce duplicated queries, improve testability, and keep business logic separate from persistence.
Should every WordPress plugin use a repository?
No. Small plugins with very simple persistence logic may not need one.
What is the difference between a repository and a service?
A service coordinates application behavior. A repository handles data retrieval and persistence.
Can repositories use $wpdb?
Yes. $wpdb is commonly used inside repositories that manage custom database tables.
Can repositories use WordPress APIs?
Yes. Repositories can wrap APIs such as get_post(), get_user_by(), metadata APIs, the Options API, and WooCommerce APIs.
Should repositories contain business logic?
Generally no. Business rules and application workflows should be handled by services or domain components.
Should repositories expose raw SQL?
Usually not. They should expose meaningful data operations such as findByCustomer() rather than arbitrary SQL execution.
What is an N+1 query problem?
It occurs when an application performs one query to retrieve a collection and then additional queries for each item in that collection.
How can a repository reduce N+1 problems?
It can provide batch methods or optimized queries that retrieve related data more efficiently.
Can AI help design repository architecture?
Yes. AI can identify repeated SQL, group data-access operations, suggest repository boundaries, and generate candidate tests.
Should AI generate repository SQL without review?
No. Database schemas, WordPress behavior, WooCommerce relationships, security, transactions, and performance requirements need developer review.
Why choose ThemeKaddora?
ThemeKaddora develops WordPress themes, plugins, WooCommerce solutions, AI products, HTML templates, UI kits, SaaS-oriented solutions, and business-focused digital products using maintainable architecture, modern PHP practices, dependency injection, modular development, API integrations, testing, performance considerations, and scalable engineering workflows.
Comments (0)