Middleware runs between route matching and controller method invocation. It controls access to routes, modifies request context, and can swap layout templates. The framework ships with three built-in middleware composition types and a set of ready-made middleware classes for common use cases.
Every middleware class extends Middleware and implements a single result() method that returns one of three things:
true — allow the request to continue to the controllerfalse — block the request, framework decides the response based on URLJsonResponse or RedirectResponse — block and return this specific responseWhen result() returns false, the framework checks whether the current URL starts with /api/:
JsonResponse with 403RedirectResponse to previous page with "Access Denied" error// App/Http/Middleware/example_middleware.php
namespace App\Http\Middleware;
use PHP_SF\System\Classes\Abstracts\Middleware;
use Symfony\Component\HttpFoundation\JsonResponse;
final class example_middleware extends Middleware
{
public function result(): bool|JsonResponse
{
// Allow all requests
return true;
}
}
The middleware class has access to:
$this->request // Symfony Request object
$this->kernel // App\Kernel instance
And two methods for swapping layout templates:
$this->changeHeaderTemplateClassName( blank_page::class );
$this->changeFooterTemplateClassName( blank_page::class );
This is how the blank middleware in the template project strips the header and footer for the welcome page — it swaps both to an empty view class and returns true, letting the request through with a blank layout.
Only one middleware is part of the framework core:
PHP_SF\Framework\Http\Middleware\auth — verifies the user is authenticated. Redirects to login_page for page routes, returns 401 JSON for API routes:
#[Route( url: 'dashboard', httpMethod: 'GET', middleware: auth::class )]
public function dashboard(): Response { ... }
Also provides static methods used throughout the application:
auth::isAuthenticated() // bool
auth::user() // UserInterface|false
auth::logInUser( $user ) // stores user in session and auth::$user
auth::logOutUser() // clears session and auth::$user
The framework ships several example middleware classes in Platform/app/Http/Middleware/ as reference implementations. They are functional but intentionally named with an _example suffix to make clear they are starting points — copy, rename, and adapt them to your application's requirements.
PHP_SF\Framework\Http\Middleware\admin_example — example showing how to restrict a route to admin users. Verifies the user is authenticated and is an administrator; returns 401/redirect for unauthenticated users, 403/redirect for authenticated non-admins:
#[Route( url: 'admin/panel', httpMethod: 'GET', middleware: admin_example::class )]
public function admin_panel(): Response { ... }
PHP_SF\Framework\Http\Middleware\api_example — example showing how to restrict an endpoint to requests from hosts listed in AVAILABLE_HOSTS. Used for internal server-to-server calls:
#[Route( url: 'api/internal/sync', httpMethod: 'POST', middleware: api_example::class )]
public function internal_sync(): JsonResponse { ... }
PHP_SF\Framework\Http\Middleware\cron_example — same IP check as api_example, semantically separate to distinguish cron-triggered endpoints from internal API calls:
#[Route( url: 'cron/daily_reset', httpMethod: 'GET', middleware: cron_example::class )]
public function daily_reset(): JsonResponse { ... }
App\Http\Middleware\blank — defined in the template project rather than the framework. Swaps header and footer to an empty view, effectively rendering the page with no layout. Used for pages that manage their own full HTML:
#[Route( url: '/', httpMethod: 'GET', middleware: blank::class )]
public function welcome_page(): Response { ... }
The real power of the middleware system is in how multiple middleware classes are composed on a single route. Three composition types are available.
Every middleware in the list must return true. On the first failure, execution stops and that middleware's response is returned:
use PHP_SF\System\Classes\MiddlewareChecks\MiddlewareAll as all;
#[Route(
url: 'api/admin/users',
httpMethod: 'GET',
middleware: [ all::class => [ auth::class, admin_example::class ] ]
)]
public function admin_users(): JsonResponse { ... }
Middleware are checked in the order they appear in the array. Put the cheapest check first — auth before admin since checking authentication is cheaper than checking admin status.
At least one middleware in the list must return true. Execution stops on the first success. If all fail, the last middleware's response is returned:
use PHP_SF\System\Classes\MiddlewareChecks\MiddlewareAny as any;
#[Route(
url: 'dashboard',
httpMethod: 'GET',
middleware: [ any::class => [ auth::class, api_example::class ] ]
)]
public function dashboard(): Response { ... }
This means "allow if the user is authenticated OR if the request comes from an internal host" — useful for endpoints that serve both browser users and internal services.
Combines MiddlewareAll and MiddlewareAny for complex access rules. MiddlewareAll always executes before MiddlewareAny regardless of declaration order:
use PHP_SF\System\Classes\MiddlewareChecks\MiddlewareAll as all;
use PHP_SF\System\Classes\MiddlewareChecks\MiddlewareAny as any;
use PHP_SF\System\Classes\MiddlewareChecks\MiddlewareCustom as custom;
#[Route(
url: 'example/page/{response_type}',
httpMethod: 'GET',
middleware: [
custom::class => [
all::class => [ auth::class ],
any::class => [ api_example::class, admin_example::class ]
]
]
)]
public function example_route( string $response_type ): Response { ... }
This reads as: "user must be authenticated AND (request must be from internal host OR user must be admin)".
The execution order is always:
MiddlewareAll checks — if any fail, stopMiddlewareAny checks — if all fail, stopWhen only one middleware is needed, pass it directly without a composition wrapper:
// These are equivalent
#[Route( url: 'dashboard', httpMethod: 'GET', middleware: auth::class )]
#[Route( url: 'dashboard', httpMethod: 'GET', middleware: [ auth::class ] )]
#[Route( url: 'dashboard', httpMethod: 'GET', middleware: [ MiddlewareAll::class => [ auth::class ] ] )]
The router normalises all three forms to MiddlewareAll with a single entry internally.
The framework validates middleware configuration at parse time (during route scanning), not at request time. Invalid configurations throw RouteMiddlewareException when routes are loaded:
MiddlewareAll and MiddlewareAny arrays must not be emptyMiddlewareMiddlewareCustom array must contain only MiddlewareAll and MiddlewareAny keysMiddlewareCustom array must have at most 2 elementsThis means misconfigured middleware fails fast on boot rather than silently failing at runtime on the first matching request.
Middleware classes implement EventSubscriberInterface, which means you can hook into middleware execution with event listeners. A listener fires after its associated middleware executes:
// App/EventListeners/ExampleEventListener.php
final class ExampleEventListener extends AbstractEventListener
{
public function getListeners(): array
{
return [
api_example::class => 'onApiMiddleware',
];
}
private function onApiMiddleware(
AbstractController $controller,
Middleware $middleware,
Request $request
): void {
// fires after api_example middleware executes on any matching request
// log the internal API call, update rate limiting counters, etc.
}
}
Listeners must be registered in config/eventListeners.php:
PHP_SF\System\Core\MiddlewareEventDispatcher::addEventListeners(
\App\EventListeners\ExampleEventListener::class,
);
Each listener class only fires once per request regardless of how many routes use the associated middleware — AbstractEventListener::markExecuted() is called after the first dispatch. See Events & Listeners for the full event system documentation.
Middleware classes extend Middleware which uses RedirectTrait, so all redirect methods are available inside middleware:
final class subscription_required extends Middleware
{
public function result(): bool|RedirectResponse
{
if ( auth::isAuthenticated() === false )
return $this->redirectTo( 'login_page' );
if ( auth::user()->hasActiveSubscription() === false )
return $this->redirectTo( 'subscription_page', messages: [
RedirectResponse::ALERT_WARNING => 'An active subscription is required.'
] );
return true;
}
}
The full Symfony Request object is available via $this->request:
final class rate_limiter extends Middleware
{
public function result(): bool|JsonResponse
{
$ip = $this->request->getClientIp();
$key = sprintf( 'rate_limit:%s', $ip );
$hits = (int) rca()->get( $key );
if ( $hits >= 100 )
return new JsonResponse(
[ 'error' => 'Rate limit exceeded.' ],
JsonResponse::HTTP_TOO_MANY_REQUESTS
);
rp()->incr( $key );
rp()->expire( $key, 60 );
return true;
}
}
Note the use of rp() (pipeline) for the counter increment and expiry — these are queued and sent at request end rather than immediately, keeping the middleware fast.
Returning false from an API middleware — when result() returns false on an API route the framework generates a generic 403 JSON response. If you need a specific error message or status code, return a JsonResponse directly rather than false.
Heavy operations in middleware — middleware runs on every matching request before the controller. Database queries, external API calls, and slow cache operations in middleware affect every request that hits the route. Keep middleware fast — check sessions, cache values, and simple conditions. Move heavy logic to the controller.
Wrong composition type — using MiddlewareAny when you mean MiddlewareAll is a common mistake. MiddlewareAny means the route is accessible if any one middleware passes — putting auth and admin in MiddlewareAny means an unauthenticated request passes if admin somehow returns true, which is probably not what you want. When in doubt, use MiddlewareAll.
Forgetting to register event listeners — if a middleware event listener isn't firing, the first thing to check is config/eventListeners.php. A listener class that isn't registered there will never execute, with no error.
Middleware class naming — middleware class names are lowercase by convention in the framework (matching auth.php, admin_example.php, api_example.php, cron_example.php). This is a convention not a requirement, but keeping it consistent makes middleware easy to distinguish from controllers and other classes at a glance.