These tools are for behavior that touches many requests at once.
If that sounds abstract, think authentication checks, trimming input, wrapping responses, or low-level HTTP hooks that should not live inside every controller method.
The main tools are:
- guards for access decisions
- exception filters for failures raised by the request pipeline
- interceptors for behavior around handler execution
- pipes for request transformation and validation
- middleware as a lower-level HTTP concept
The first four are the clearest day-to-day tools for request handling. Middleware has an API surface too, but it is still the more cautious topic, so this guide treats it accordingly.
Generate the building blocks
The CLI can scaffold three of these concerns directly:
assegai generate guard auth
assegai generate interceptor empty-result
assegai generate pipe trim-strings
That creates feature folders like:
src/Auth/AuthGuard.php
src/EmptyResult/EmptyResultInterceptor.php
src/TrimStrings/TrimStringsPipe.php
These generators are useful because they keep the framework-facing interfaces in place from the start.
Guards
Guards answer one question:
Should this handler be allowed to run?
They implement ICanActivate and receive an execution context.
If you want reusable authentication helpers for those guards, assegaiphp/auth ships session and JWT strategies. It helps with credential verification and auth state, but your app still owns user lookup, login endpoints, and the guard logic that decides whether a request should proceed.
A generated guard
<?php
namespace Assegaiphp\BlogApi\Auth;
use Assegai\Core\Attributes\Injectable;
use Assegai\Core\Interfaces\ICanActivate;
use Assegai\Core\Interfaces\IExecutionContext;
#[Injectable]
class AuthGuard implements ICanActivate
{
public function canActivate(IExecutionContext $context): bool
{
return true;
}
}
Apply a guard to a controller
<?php
use Assegai\Core\Attributes\Controller;
use Assegai\Core\Attributes\Http\Get;
use Assegai\Core\Attributes\UseGuards;
use Assegaiphp\BlogApi\Auth\AuthGuard;
#[UseGuards(AuthGuard::class)]
#[Controller('admin')]
class AdminController
{
#[Get]
public function index(): array
{
return ['area' => 'admin'];
}
}
Apply a guard to a single handler
<?php
use Assegai\Core\Attributes\Http\Delete;
use Assegai\Core\Attributes\Param;
use Assegai\Core\Attributes\UseGuards;
use Assegaiphp\BlogApi\Auth\AuthGuard;
#[Delete(':id<int>')]
#[UseGuards(AuthGuard::class)]
public function deleteById(#[Param('id')] int $id): array
{
return ['deleted' => $id];
}
Guards are the right place for:
- authentication
- role checks
- tenant checks
- feature flags
The UseGuards attribute also accepts a custom exception class if you want a different failure response than the default forbidden path.
Exception filters
Exception filters decide how a raised failure becomes an HTTP response. They are the natural companion to a guard when browser routes and API routes need different responses to the same access decision.
For example, a session guard can raise UnauthorizedException for every unauthenticated request. A protected browser controller can opt into a login redirect:
<?php
use Assegai\Core\Attributes\Controller;
use Assegai\Core\Attributes\UseFilters;
use Assegai\Core\Attributes\UseGuards;
use Assegai\Core\Exceptions\Filters\LoginRedirectFilter;
use Assegai\Core\Exceptions\Http\UnauthorizedException;
use Assegaiphp\BlogApi\Auth\SessionAuthGuard;
#[Controller('dashboard')]
#[UseGuards(SessionAuthGuard::class, UnauthorizedException::class)]
#[UseFilters(LoginRedirectFilter::class)]
final class DashboardController
{
}
The same guard on an API controller, without LoginRedirectFilter, returns the normal 401 response. The guard does not need to know whether the current transport policy is a redirect or an API error.
The built-in filter reads its options from authentication.loginRedirect in config/auth.php, including the application-owned login URL. It is opt-in and is never enabled globally just because the configuration exists.
Filter scopes and precedence
Filters may be registered on a handler, a controller, or globally with useGlobalFilters().
When several scopes match the same exception, Core uses this order:
- handler
- controller
- global
The first matching filter is terminal. It owns the response, lower-precedence filters do not run, and Core does not emit a second exception response.
Class-name filters are resolved through dependency injection. You can also pass a configured filter instance when one controller needs a policy that differs from the application default. For behavior beyond the built-in redirect options, implement ExceptionFilterInterface and apply that custom filter.
Why the redirect is not an interceptor
An interceptor wraps handler execution. An unauthenticated guard prevents that handler from executing at all, so a login redirect belongs on the failure path handled by an exception filter.
The combined flow is:
guard -> UnauthorizedException -> matching exception filter -> redirect or normal 401
That separation lets one guard serve browser and API controllers without mixing access policy with response policy.
Interceptors
Interceptors wrap handler execution. They are useful when the concern is not "who is allowed in?" but rather "what should happen around this call?"
That makes them a good fit for:
- transforming results
- normalizing empty responses
- timing or logging work
- response decoration
A generated interceptor
<?php
namespace Assegaiphp\BlogApi\EmptyResult;
use Assegai\Core\Attributes\Injectable;
use Assegai\Core\ExecutionContext;
use Assegai\Core\Interfaces\IAssegaiInterceptor;
#[Injectable]
class EmptyResultInterceptor implements IAssegaiInterceptor
{
public function intercept(ExecutionContext $context): ?callable
{
return function () use ($context) {
return $context;
};
}
}
Use a built-in interceptor
Core ships with EmptyResultInterceptor, which is useful when an empty result should become a 404.
<?php
use Assegai\Core\Attributes\Http\Get;
use Assegai\Core\Attributes\Param;
use Assegai\Core\Attributes\UseInterceptors;
use Assegai\Core\Interceptors\EmptyResultInterceptor;
#[Get(':id<int>')]
#[UseInterceptors(EmptyResultInterceptor::class)]
public function findById(#[Param('id')] int $id): array
{
return [];
}
Register a global interceptor
Interceptors can also be added at app level:
<?php
use Assegai\Core\AssegaiFactory;
use Assegai\Core\Interceptors\EmptyResultInterceptor;
use Assegaiphp\BlogApi\AppModule;
require __DIR__ . '/vendor/autoload.php';
function bootstrap(): void
{
$app = AssegaiFactory::createFromProject(AppModule::class, __DIR__);
$app->useGlobalInterceptors(EmptyResultInterceptor::class);
$app->run();
}
bootstrap();
That gives you a clean way to apply one behavior consistently across many handlers.
Pipes
Pipes transform or validate request data before it reaches the service layer.
This is where Assegai starts to feel especially Nest-like:
- handlers stay small
- DTOs stay meaningful
- request cleanup happens at the edge
The built-in pipe surface includes:
ValidationPipeParseIntPipeParseBoolPipeParseFloatPipeParseArrayPipeParseFilePipeMapProperties
The most practical walkthrough is in Request Data and Validation, but the short version is:
<?php
use Assegai\Core\Attributes\Http\Body;
use Assegai\Core\Attributes\Http\Post;
use Assegaiphp\BlogApi\Posts\DTOs\CreatePostDTO;
use Assegaiphp\BlogApi\TrimStrings\TrimStringsPipe;
#[Post]
public function create(
#[Body(pipes: TrimStringsPipe::class)] CreatePostDTO $dto,
): mixed {
return $this->postsService->create($dto);
}
At app level, App::useGlobalPipes(...) applies pipes globally. Bind a pipe directly to a request parameter such as #[Body(pipes: ...)] when it belongs to one route.
Middleware
Middleware uses:
MiddlewareInterfaceMiddlewareConsumerRouteAssegaiModuleInterface::configure(...)
That tells us the intended model:
- a module can configure middleware
- middleware can be applied to routes
- middleware runs at the HTTP layer
Middleware is a lower-level surface than guards, interceptors, and pipes.
The practical guidance is:
- reach for guards when the concern is authorization
- reach for interceptors when the concern wraps handler execution
- reach for pipes when the concern is request transformation or validation
- use middleware only when the concern belongs at the lower-level HTTP boundary
Which tool to choose
Use a guard when:
- the handler should be blocked unless a condition is true
Use an exception filter when:
- a thrown failure needs scoped or transport-specific response handling
Use an interceptor when:
- the handler should still run, but the framework should do something around it
Use a pipe when:
- the request data should be validated or transformed before the handler uses it
Use middleware when:
- you need lower-level HTTP request/response behavior; cover the path with an integration test