📦 Marketplace⭐ GitHub
Guidesv2.0

Middleware, Caching & Advanced Patterns

Custom middleware, caching strategies, testing, and performance optimization.


Table of Contents


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

CommandDescription
php vendor/bin/ml listList all available commands
php vendor/bin/ml key:generateGenerate APP_KEY
php vendor/bin/ml schema:updateSync database schema from entities
php vendor/bin/ml db:wipeDrop all tables

Queue Commands

CommandDescription
php vendor/bin/ml queue:workStart queue worker
php vendor/bin/ml queue:failedList failed jobs
php vendor/bin/ml queue:retryRetry failed jobs
php vendor/bin/ml queue:flushFlush failed jobs
php vendor/bin/ml queue:clearClear pending jobs
php vendor/bin/ml queue:statsQueue statistics

Schedule Commands

CommandDescription
php vendor/bin/ml schedule:runRun due scheduled tasks
php vendor/bin/ml schedule:listList all schedules

Development

CommandDescription
composer serveDev server with hot-reload (port 8000)
composer devSimple dev server (port 8080)
composer testRun all tests

Quick Navigation

GuideTopic
1Your First Application
2Building a REST API
3Authentication
4Templates & Views
5Events & Queues
6This guide — Middleware, Caching & Advanced

MonkeysLegion v2 — Built with 🐵 by MonkeysCloud Team