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
{
protected function result(): bool|JsonResponse|RedirectResponse
{
// Allow all requests
return true;
}
}
The middleware class has access to the current request via the global r() helper:
$ip = r()->getClientIp();
And two methods for swapping layout templates (deprecated since v3.0.0 — prefer the static calls below):
// Deprecated — still works, removed in v4
$this->changeHeaderTemplateClassName( blank_page::class );
$this->changeFooterTemplateClassName( blank_page::class );
// Preferred since v3.0.0
use PHP_SF\System\Kernel;
Kernel::setHeaderTemplateClassName( blank_page::class );
Kernel::setFooterTemplateClassName( 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.
Global middleware runs before every matched route's own middleware, without any per-route configuration. Register it in public/index.php before Router::init():
Router::addGlobalMiddleware( csrf::class );
Router::init( $kernel );
addGlobalMiddleware() accepts one or more class names. All registered classes run on every matched route, in registration order, before the route's own middleware. Each global middleware still controls its own skip logic — it can inspect Router::$currentRoute and return true early for requests it should not affect.
Global middleware is designed for cross-cutting concerns that should apply application-wide:
The framework ships three ready-made middleware classes in Platform/app/Http/Middleware/:
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
PHP_SF\Framework\Http\Middleware\csrf — global CSRF validation. Registered via Router::addGlobalMiddleware( csrf::class ) in public/index.php. Validates the _token POST field against a per-session token for all mutating requests (POST, PUT, PATCH, DELETE).
Automatically skips:
GET, HEAD, OPTIONS)/api/no_csrf::class anywhere in their own middleware listOn validation failure it calls redirectBack() with an ALERT_DANGER error message.
The CSRF token is generated and stored in the user session on first use. In templates, use the csrf_token() helper to embed the hidden input — see Helper Functions:
<form method="POST" action="<?= routeLink( 'my_route' ) ?>">
<?= csrf_token( asInput: true ) ?>
<!-- fields -->
</form>
PHP_SF\Framework\Http\Middleware\no_csrf — marker middleware that opts a specific route out of global CSRF validation. Always returns true (never blocks the request itself). Its presence in the route's middleware list is detected by the csrf global middleware:
// CSRF validated automatically (default — nothing to add)
#[Route( url: 'form/submit', httpMethod: 'POST', middleware: [ auth::class ] )]
public function submit(): RedirectResponse { ... }
// Opt out — webhook, internal API call, etc.
#[Route( url: 'webhook/stripe', httpMethod: 'POST', middleware: [ no_csrf::class ] )]
public function stripe_webhook(): JsonResponse { ... }
// Opt out alongside other middleware
#[Route( url: 'api/internal/sync', httpMethod: 'POST', middleware: [ api_example::class, no_csrf::class ] )]
public function internal_sync(): JsonResponse { ... }
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.
The framework fires standard Symfony kernel events at each stage of request dispatch. To hook into request handling, create a class in App/EventSubscriber/ that implements EventSubscriberInterface — it is discovered and registered automatically. See Events & Listeners for full documentation.
Middleware classes extend Middleware which uses RedirectTrait, so all redirect methods are available inside middleware:
final class subscription_required extends Middleware
{
protected 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 the r() global helper:
final class rate_limiter extends Middleware
{
protected function result(): bool|JsonResponse
{
$ip = r()->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.
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.