Middleware, Caching & Advanced Patterns
Custom middleware, caching strategies, testing, and performance optimization.
Table of Contents
- Custom Middleware
- Global vs Route Middleware
- Caching Strategies
- Tagged Cache
- Query Result Caching
- Service Providers
- Testing
- Performance Optimization
- CLI Commands
Custom Middleware
Middleware intercepts HTTP requests and responses — perfect for logging, auth checks, CORS, rate limiting, and more.
Create Middleware
Create app/Middleware/ApiVersionMiddleware.php:
<?php
declare(strict_types=1);
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class ApiVersionMiddleware implements MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface {
// Add version info to request attributes
$request = $request->withAttribute('api_version', 'v2');
// Pass to next middleware / controller
$response = $handler->handle($request);
// Add version header to response
return $response
->withHeader('X-API-Version', '2.0')
->withHeader('X-Powered-By', 'MonkeysLegion');
}
}
Request Timing Middleware
<?php
declare(strict_types=1);
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class TimingMiddleware implements MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface {
$start = hrtime(true);
$response = $handler->handle($request);
$durationMs = (hrtime(true) - $start) / 1e6;
return $response->withHeader(
'Server-Timing',
sprintf('total;dur=%.2f', $durationMs),
);
}
}
Maintenance Mode Middleware
<?php
declare(strict_types=1);
namespace App\Middleware;
use MonkeysLegion\Http\Message\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class MaintenanceMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly bool $maintenanceMode = false,
private readonly array $allowedIps = [],
) {}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface {
if (!$this->maintenanceMode) {
return $handler->handle($request);
}
$clientIp = $request->getServerParams()['REMOTE_ADDR'] ?? '';
if (in_array($clientIp, $this->allowedIps, true)) {
return $handler->handle($request);
}
return Response::json(
['error' => 'Service is temporarily unavailable'],
503,
);
}
}
Global vs Route Middleware
Global Middleware
Runs on every request. Configured in config/middleware.mlc:
middleware {
global = [
"MonkeysLegion\\Http\\Middleware\\ErrorHandlerMiddleware",
"MonkeysLegion\\Http\\Middleware\\SecurityHeadersMiddleware",
"MonkeysLegion\\Http\\Middleware\\CorsMiddleware",
"App\\Middleware\\TimingMiddleware",
]
}
Route-Level Middleware
Applied to specific controllers or methods:
// Entire controller
#[Middleware(['auth', 'throttle:60,1'])]
final class AdminController { ... }
// Single method
#[Route('DELETE', '/{id}', name: 'posts.destroy')]
#[Middleware(['auth'])]
public function destroy(string $id): Response { ... }
Middleware Execution Order
Request ──▶ Global[0] ──▶ Global[1] ──▶ ... ──▶ Route Middleware ──▶ Controller
│
Response ◀── Global[0] ◀── Global[1] ◀── ... ◀── Route Middleware ◀────────┘
Each middleware can:
- Modify the request before passing it downstream
- Short-circuit and return a response (e.g., 401, 503)
- Modify the response on the way back up
Caching Strategies
Basic Cache Usage
use MonkeysLegion\Cache\CacheManager;
final class ProductService
{
public function __construct(
private readonly CacheManager $cache,
private readonly ProductRepository $products,
) {}
public function getFeatured(): array
{
// Check cache first, compute if missing
return $this->cache->remember('products:featured', 3600, function () {
return $this->products->findBy(
criteria: ['featured' => true, 'active' => true],
orderBy: ['name' => 'ASC'],
);
});
}
}
Cache Operations
// Store
$this->cache->set('key', $value, ttl: 3600); // 1 hour
// Retrieve
$value = $this->cache->get('key', default: null);
// Check
if ($this->cache->has('key')) { ... }
// Delete
$this->cache->delete('key');
// Batch
$this->cache->setMultiple([
'key1' => $value1,
'key2' => $value2,
], ttl: 600);
$values = $this->cache->getMultiple(['key1', 'key2']);
Cache Invalidation
// Delete specific key
$this->cache->delete('products:featured');
// Clear everything
$this->cache->clear();
// Invalidate on entity change
public function update(int $id, UpdateProductRequest $dto): Product
{
$product = $this->products->findOrFail($id);
// ... update fields ...
$this->products->persist($product);
// Invalidate related caches
$this->cache->delete("products:{$id}");
$this->cache->delete('products:featured');
return $product;
}
Tagged Cache
Group cached items by tags for bulk invalidation:
// Store with tags
$this->cache->tags(['products', 'category:electronics'])->set(
'products:list:electronics',
$products,
3600,
);
$this->cache->tags(['products'])->set(
'products:stats',
$stats,
3600,
);
// Invalidate all items tagged 'products'
$this->cache->tags(['products'])->clear();
// Both 'products:list:electronics' and 'products:stats' are cleared
// Invalidate only electronics
$this->cache->tags(['category:electronics'])->clear();
Query Result Caching
Cache QueryBuilder results directly:
$products = $this->products->query()
->where('active', '=', true)
->orderBy('name')
->cache(ttl: 600, key: 'active-products') // Cache for 10 minutes
->get();
Setup
// In your repository or service provider
$queryBuilder->setCache($cacheManager, defaultTtl: 60);
Service Providers
Service providers bootstrap parts of your application — register DI bindings, configure services, and connect listeners.
Create a Provider
Create app/Providers/AppServiceProvider.php:
<?php
declare(strict_types=1);
namespace App\Providers;
use MonkeysLegion\DI\Attributes\Provider;
#[Provider]
final class AppServiceProvider
{
public function register(\MonkeysLegion\DI\Container $container): void
{
// Bind interfaces to implementations
$container->bind(
PaymentGatewayInterface::class,
fn($c) => new StripeGateway($c->get('config')->get('services.stripe.key')),
);
}
public function boot(\MonkeysLegion\DI\Container $container): void
{
// Run after all providers are registered
$view = $container->get(MLView::class);
$view->share('appName', $container->get('config')->get('app.name'));
}
}
DI Container Overrides
For simple bindings, use config/app.php:
<?php
return [
// Interface → Concrete implementation
App\Contract\PaymentGateway::class => fn($c) => $c->get(App\Service\StripeGateway::class),
// Singleton factory
Redis::class => function ($c) {
$redis = new Redis();
$redis->connect($c->get('config')->get('redis.host'));
return $redis;
},
];
Testing
PHPUnit Setup
composer test # All tests
composer test:unit # Unit tests only
composer test:feature # Feature tests only
Unit Test: Service
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use PHPUnit\Framework\TestCase;
use App\Service\ProductService;
use App\Repository\ProductRepository;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\NullLogger;
final class ProductServiceTest extends TestCase
{
public function testCreateProduct(): void
{
$repo = $this->createMock(ProductRepository::class);
$repo->expects($this->once())->method('persist');
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects($this->once())->method('dispatch');
$service = new ProductService($repo, $events, new NullLogger());
$dto = new CreateProductRequest(
name: 'Test Widget',
description: 'A test product',
price: 19.99,
);
$product = $service->create($dto);
$this->assertEquals('Test Widget', $product->name);
$this->assertEquals(19.99, $product->price);
}
}
Unit Test: Entity Property Hooks
<?php
declare(strict_types=1);
namespace Tests\Unit\Entity;
use PHPUnit\Framework\TestCase;
use App\Entity\Product;
final class ProductTest extends TestCase
{
public function testSlugGeneration(): void
{
$product = new Product();
$product->slug = 'Hello World Product!';
$this->assertEquals('hello-world-product', $product->slug);
}
public function testEmptyNameThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
$product = new Product();
$product->name = '';
}
public function testFormattedPrice(): void
{
$product = new Product();
$product->price = 29.5;
$this->assertEquals('$29.50', $product->formattedPrice);
}
}
Feature Test: API Endpoint
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Tests\Feature\FeatureTestCase;
final class ProductApiTest extends FeatureTestCase
{
public function testListProducts(): void
{
$response = $this->get('/api/v2/products/');
$this->assertResponseStatusCode(200, $response);
$this->assertJsonStructure($response, [
'data' => [['id', 'type', 'attributes']],
'meta' => ['total'],
]);
}
public function testCreateRequiresAuth(): void
{
$response = $this->post('/api/v2/products/', [
'name' => 'Test',
'description' => 'Test product',
'price' => 10.00,
]);
$this->assertResponseStatusCode(401, $response);
}
}
Performance Optimization
OPcache Preloading
Create config/preload.php:
<?php
require __DIR__ . '/../vendor/autoload.php';
// Preload framework classes for faster boot
$classes = [
\MonkeysLegion\Framework\Application::class,
\MonkeysLegion\Http\Message\Response::class,
\MonkeysLegion\Router\Attributes\Route::class,
// Add your most-used classes
];
foreach ($classes as $class) {
if (!class_exists($class, false)) {
class_exists($class);
}
}
In php.ini:
opcache.preload=/path/to/app/config/preload.php
opcache.preload_user=www-data
Database: Statement Caching
QueryBuilder automatically caches prepared statements per connection. No configuration needed.
Database: Identity Map
EntityRepository uses an identity map — the same entity is never loaded twice in a single request:
$post1 = $this->posts->find(1); // DB query
$post2 = $this->posts->find(1); // Returns cached instance
$post1 === $post2; // true — same object
Redis for Everything
In production, use Redis for all ephemeral storage:
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
CLI Commands
Framework Commands
| Command | Description |
|---|---|
php vendor/bin/ml list | List all available commands |
php vendor/bin/ml key:generate | Generate APP_KEY |
php vendor/bin/ml schema:update | Sync database schema from entities |
php vendor/bin/ml db:wipe | Drop all tables |
Queue Commands
| Command | Description |
|---|---|
php vendor/bin/ml queue:work | Start queue worker |
php vendor/bin/ml queue:failed | List failed jobs |
php vendor/bin/ml queue:retry | Retry failed jobs |
php vendor/bin/ml queue:flush | Flush failed jobs |
php vendor/bin/ml queue:clear | Clear pending jobs |
php vendor/bin/ml queue:stats | Queue statistics |
Schedule Commands
| Command | Description |
|---|---|
php vendor/bin/ml schedule:run | Run due scheduled tasks |
php vendor/bin/ml schedule:list | List all schedules |
Development
| Command | Description |
|---|---|
composer serve | Dev server with hot-reload (port 8000) |
composer dev | Simple dev server (port 8080) |
composer test | Run all tests |
Quick Navigation
| Guide | Topic |
|---|---|
| 1 | Your First Application |
| 2 | Building a REST API |
| 3 | Authentication |
| 4 | Templates & Views |
| 5 | Events & Queues |
| 6 | This guide — Middleware, Caching & Advanced |
MonkeysLegion v2 — Built with 🐵 by MonkeysCloud Team