cadabra / php
PHP client and Symfony bundle for Cadabra query cache with Doctrine DBAL middleware
Installs: 3
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/cadabra/php
Requires
- php: >=8.2
- doctrine/dbal: ^3.6|^4.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/log: ^3.0
- symfony/config: ^6.4|^7.0
- symfony/dependency-injection: ^6.4|^7.0
- symfony/http-client: ^6.4|^7.0
- symfony/http-kernel: ^6.4|^7.0
Requires (Dev)
- doctrine/orm: ^2.17|^3.0
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^1.12|^2.0
- phpstan/phpstan-phpunit: ^1.4|^2.0
- phpstan/phpstan-strict-rules: ^1.6|^2.0
- phpunit/phpunit: ^10.5
README
Opt-in query caching for Symfony applications with Doctrine ORM. Intercepts at DBAL level for transparent integration.
Note: This package requires a running Cadabra server. The server handles SQL normalization, cache key generation, and invalidation logic.
Why This Works
This bundle intercepts database queries after SQL generation but before execution (DBAL Statement::execute level). It caches raw database arrays before ORM hydration, allowing Doctrine to hydrate entities normally. All Doctrine features work: UnitOfWork, lazy loading, lifecycle events, etc.
Key Design Principles
1. Opt-In Caching: Queries are NOT cached by default. You explicitly mark queries for caching using ->useCadabraCache() or the /* CADABRA:USE */ comment. This keeps cache size low and gives you full control.
2. No Logic Duplication: This client sends raw SQL to the Cadabra server without any normalization. The server handles all SQL normalization, cache key generation, and invalidation logic. This ensures consistent behavior across all clients (PHP, TypeScript, etc.).
3. Automatic Invalidation: Write queries (INSERT/UPDATE/DELETE) always trigger invalidation - no configuration needed. The server intelligently determines which cache entries to invalidate.
The Architecture
User Code
â
Doctrine Repository (find/createQueryBuilder/etc.)
â
Doctrine ORM (generates DQL)
â
Doctrine DBAL (converts to SQL: "SELECT t0.id FROM users t0 WHERE t0.id = ?")
â
ðŊ CadabraMiddleware (intercepts here)
ââ Check for /* CADABRA:USE */ comment
ââ If NO comment â Execute directly (no caching)
ââ If YES â Send RAW SQL to Cadabra server for analysis
ââ Server normalizes (t0 â u) and generates cache key fingerprint
ââ Check server cache by fingerprint
ââ Return CachedResult if hit
ââ Execute & register with server if miss
â
Database (on cache miss or when not using Cadabra)
â
Doctrine ORM (hydrates entities from cached arrays)
â
Your Entity Objects
Installation
composer require cadabra/php
Quick Start
1. Register the Bundle
// config/bundles.php return [ // ... Cadabra\SymfonyBundle\CadabraBundle::class => ['all' => true], ];
2. Configure Cadabra
# config/packages/cadabra.yaml cadabra: service_url: '%env(CADABRA_SERVICE_URL)%' # Required prefix: '%env(APP_ENV)%_myapp' # Optional, default: 'cadabra'
3. Set Environment Variables
# .env CADABRA_SERVICE_URL=http://localhost:6942
4. Integrate CadabraQueryBuilder (Recommended)
Create a base repository class that returns CadabraQueryBuilder instead of the default QueryBuilder:
// src/Repository/CadabraRepository.php namespace App\Repository; use Cadabra\SymfonyBundle\ORM\CadabraQueryBuilder; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; abstract class CadabraRepository extends ServiceEntityRepository { public function createQueryBuilder($alias, $indexBy = null): CadabraQueryBuilder { return (new CadabraQueryBuilder($this->getEntityManager())) ->select($alias) ->from($this->getEntityName(), $alias, $indexBy); } }
Then extend it in your repositories:
// src/Repository/UserRepository.php namespace App\Repository; use App\Entity\User; class UserRepository extends CadabraRepository { // Now createQueryBuilder() returns CadabraQueryBuilder // which has ->useCadabraCache() method available }
5. Mark Queries for Caching
// Enable caching for specific queries $users = $repository->createQueryBuilder('u') ->where('u.status = :status') ->setParameter('status', 'active') ->useCadabraCache() // â Opt-in to caching ->getQuery() ->getResult();
Usage
Opt-In Caching with QueryBuilder
Method 1: Using CadabraQueryBuilder (Recommended)
// After integrating CadabraRepository (see Quick Start) public function findActiveUsers(): array { return $this->createQueryBuilder('u') ->where('u.status = :status') ->setParameter('status', 'active') ->useCadabraCache() // â Enable caching ->getQuery() ->getResult(); } // Queries without ->useCadabraCache() are NOT cached public function findUserForUpdate(int $id): ?User { return $this->createQueryBuilder('u') ->where('u.id = :id') ->setParameter('id', $id) // No ->useCadabraCache() = no caching (good for transactions) ->getQuery() ->getOneOrNullResult(); }
Method 2: Using Trait in Custom QueryBuilder
If you already have a custom QueryBuilder:
namespace App\ORM; use Cadabra\SymfonyBundle\ORM\CadabraQueryBuilderTrait; use Doctrine\ORM\QueryBuilder; class MyCustomQueryBuilder extends QueryBuilder { use CadabraQueryBuilderTrait; // Your custom methods here }
Method 3: Using Magic Comment (Raw SQL)
// Cache this query $sql = '/* CADABRA:USE */ SELECT * FROM users WHERE status = ?'; $stmt = $conn->prepare($sql); $result = $stmt->execute(['active']); // Don't cache this query (default behavior) $sql = 'SELECT * FROM users WHERE status = ?'; $stmt = $conn->prepare($sql); $result = $stmt->execute(['active']);
Automatic Invalidation (All Write Queries)
All write queries trigger automatic invalidation - no opt-in required:
// INSERT - automatically triggers invalidation $user = new User(); $user->setName('John'); $em->persist($user); $em->flush(); // â Server invalidates relevant cache entries // UPDATE - automatically triggers invalidation $user->setEmail('new@example.com'); $em->flush(); // â Server invalidates cache entries for this user // DELETE - automatically triggers invalidation $em->remove($user); $em->flush(); // â Server invalidates cache entries for this user
The Cadabra server intelligently determines which cache entries to invalidate based on:
- Tables affected
- Rows modified
- Columns changed
Example: Cache Hit Flow
// First call - cache MISS $user = $repository->createQueryBuilder('u') ->where('u.email = :email') ->setParameter('email', 'john@example.com') ->useCadabraCache() ->getQuery() ->getOneOrNullResult(); // Doctrine generates SQL: "SELECT t0.id, t0.name, t0.email FROM users t0 WHERE t0.email = ?" // CadabraMiddleware sees /* CADABRA:USE */ comment // â Sends RAW SQL to server // â Server normalizes and generates fingerprint // â Cache MISS // â Executes real query, returns: [['id' => 10, 'name' => 'John', 'email' => 'john@example.com']] // â Registers with server for caching and invalidation tracking // â Returns CachedResult to Doctrine // â Doctrine hydrates to User entity // Second call - cache HIT $user = $repository->createQueryBuilder('u') ->where('u.email = :email') ->setParameter('email', 'john@example.com') ->useCadabraCache() ->getQuery() ->getOneOrNullResult(); // â Sends RAW SQL to server // â Server recognizes same fingerprint // â Cache HIT - returns cached array directly // â Doctrine hydrates from cached data // â Result: User entity (no database query executed!)
Features
â Lazy Loading Works
$user = $repo->createQueryBuilder('u') ->where('u.id = :id') ->setParameter('id', 10) ->useCadabraCache() ->getQuery() ->getOneOrNullResult(); // Cached $orders = $user->getOrders(); // Lazy load - NEW query, can also be cached if marked
â Transactions Work
$em->beginTransaction(); try { $user->setEmail('new@example.com'); $em->flush(); // Invalidation triggered $em->commit(); } catch (\Exception $e) { $em->rollback(); // No invalidation on rollback }
â All Doctrine Features Work
- â UnitOfWork change tracking
- â Lifecycle events (PrePersist, PostLoad, etc.)
- â Entity listeners
- â Proxy objects for lazy loading
- â Cascade operations
- â Orphan removal
- â Doctrine's second-level cache (independent layer)
Configuration
Available Settings
service_url (required)
URL of the Cadabra server. The server handles SQL normalization, cache key generation, and cache storage.
prefix (optional, default: 'cadabra')
Cache key prefix/namespace. Use different prefixes for different environments or applications sharing the same Cadabra server.
# config/packages/cadabra.yaml cadabra: service_url: 'http://localhost:6942' prefix: 'prod_myapp' # Different prefix per environment
Cache Storage
Cache is stored on the Cadabra server, not locally. This provides:
- Shared cache across multiple app servers
- Centralized invalidation - one server writes, all servers' cache updated
- No local memory overhead - cache lives on dedicated server
- Persistent cache - survives app restarts
When to Use Caching
â Good Candidates for Caching
Mark these queries with ->useCadabraCache():
- Read-heavy queries: Product catalogs, user profiles, category lists
- Expensive queries: Complex JOINs, aggregations, GROUP BY
- Frequently accessed data: Homepage content, navigation menus
- Paginated lists: Search results, product listings
- Static-ish data: Settings, configurations, rarely updated content
â Don't Cache These
Leave these queries without ->useCadabraCache():
- Financial transactions: Require real-time accuracy
- Queries with FOR UPDATE locks: Transaction-sensitive
- Audit logs: Frequently changing, must be current
- Real-time data: Stock prices, live scores
- One-time queries: Reports, exports
- Development/debugging: When you need to see immediate changes
Advanced Usage
Alternative: Use Trait in Custom QueryBuilder
If you have an existing custom QueryBuilder and can't change repositories:
namespace App\ORM; use Cadabra\SymfonyBundle\ORM\CadabraQueryBuilderTrait; use Doctrine\ORM\QueryBuilder; class AppQueryBuilder extends QueryBuilder { use CadabraQueryBuilderTrait; // Your existing methods here }
Then configure your EntityManager to use it:
# config/packages/doctrine.yaml doctrine: orm: query_builder_class: App\ORM\AppQueryBuilder
Manual Cache Control
use Cadabra\Client\CadabraClient; class CacheService { public function __construct(private CadabraClient $client) {} public function clearTableCache(string $table): void { // Manually clear cache for a specific table $this->client->clearTable($table); } public function getStats(): array { return $this->client->getStats(); } }
Monitoring & Debugging
Enable debug logging to see cache hits/misses:
# config/packages/monolog.yaml monolog: handlers: cadabra: type: stream path: '%kernel.logs_dir%/cadabra.log' level: debug channels: ['cadabra']
Log output:
[2024-01-15 10:23:45] cadabra.DEBUG: Cache HIT {"sql":"SELECT...","fingerprint":"abc123"}
[2024-01-15 10:23:46] cadabra.DEBUG: Cache MISS {"sql":"SELECT...","fingerprint":"def456"}
[2024-01-15 10:23:47] cadabra.DEBUG: Invalidation queued {"sql":"UPDATE users..."}
Performance
Typical Results
With opt-in caching on appropriate queries:
- Cache hit rate: 80-95% for marked queries
- Response time improvement: 2-5x faster for cached queries
- Database load reduction: 60-80% fewer queries on cached operations
Overhead
- Cache miss: +2-5ms (server analysis + caching)
- Cache hit: +0.5-1ms (much faster than database)
- Invalidation: Async, zero overhead on writes
Benchmark Results
From integration tests (50 iterations):
| Query Type | Cold (No Cache) | With Cache | Speedup |
|---|---|---|---|
| Simple lookup | 1.35ms | 634Ξs | 2.1x |
| JOIN with pagination | 648Ξs | 153Ξs | 4.2x |
| Price range filter | 6.01ms | 1.74ms | 3.5x |
| GROUP BY aggregate | 539Ξs | 144Ξs | 3.7x |
| Complex aggregate | 719Ξs | 151Ξs | 4.8x |
Average speedup: 2.8x faster
Server Setup
Using Docker (Recommended)
docker pull ghcr.io/sebastiaanwouters/cadabra:latest docker run -d -p 6942:6942 --name cadabra-server \ ghcr.io/sebastiaanwouters/cadabra:latest
Verify Server is Running
curl http://localhost:6942/health
# Should return: {"status":"ok"}
From Source
git clone https://github.com/SebastiaanWouters/cadabra cd cadabra # See repository README for setup instructions
Development
Running Tests
# Run all tests composer test # Run only unit tests composer test:unit # Run integration tests (requires Cadabra server) cd symfony-test-app vendor/bin/phpunit # Check code style composer cs:check # Auto-fix code style composer cs:fix # Run all checks composer check
Testing in Your Application
Option 1: Disable Cadabra in tests (queries execute directly):
# config/packages/test/cadabra.yaml cadabra: service_url: 'http://localhost:6942' # Point to test server
Option 2: Mock the Cadabra client:
// In your test $mockClient = $this->createMock(CadabraClient::class); $mockClient->method('get')->willReturn(['id' => 1, 'name' => 'Test']);
Option 3: Use in-memory test database (fastest, most isolated):
# config/packages/test/doctrine.yaml doctrine: dbal: url: 'sqlite:///:memory:'
Troubleshooting
Queries Not Being Cached
Check 1: Did you add ->useCadabraCache()?
// â NOT cached (missing ->useCadabraCache()) $users = $repo->createQueryBuilder('u') ->where('u.status = :status') ->getQuery() ->getResult(); // â Cached $users = $repo->createQueryBuilder('u') ->where('u.status = :status') ->useCadabraCache() // â Added ->getQuery() ->getResult();
Check 2: Is CadabraQueryBuilder being used?
// Verify your repository extends CadabraRepository class UserRepository extends CadabraRepository // â Must extend this { // ... }
Check 3: Is the server running?
curl http://localhost:6942/health
Check 4: Enable debug logging to see what's happening:
# config/packages/monolog.yaml monolog: handlers: main: level: debug
Stale Data After Updates
If you see stale data after writes:
- Check write query executed: Updates/deletes should trigger invalidation automatically
- Check server logs: Look for invalidation messages
- Manually clear cache:
$this->cadabraClient->clearTable('users');
Performance Issues
If caching makes queries slower:
- Check network latency to Cadabra server
- Verify server health:
curl http://localhost:6942/health - Consider query complexity: Very simple queries might be faster without caching
- Use caching selectively: Only mark expensive queries with
->useCadabraCache()
Production Checklist
- Cadabra service running and healthy
- Prefix set to environment-specific value:
prod_myapp - Only expensive/frequently-accessed queries marked with
->useCadabraCache() - Monitoring and logging configured
- Cache stats monitored (hit rate, performance)
- Load testing performed with caching enabled
How This Differs from Doctrine Cache
| Feature | Doctrine Result Cache | Cadabra |
|---|---|---|
| Caching Strategy | Manual opt-in per query | Manual opt-in per query |
| Interception Level | Result set (after hydration) | DBAL (before hydration) |
| Invalidation | Manual/TTL only | Automatic on all writes |
| Granularity | Query-based | Row/column-aware |
| Storage | Local (per server) | Centralized server |
| Normalization | None | Server-side SQL normalization |
| Multi-server | Each server has own cache | Shared cache across servers |
License
MIT