PHP SDK
Attribute-Based Access Control (ABAC) for Symfony using SAPL (Streaming Attribute Policy Language). The sapl/sapl-php bundle provides attribute-driven policy enforcement on controller actions and service methods, a constraint handler architecture for obligations and advice, continuous authorization for streaming responses, and optional data-layer query rewriting through Doctrine.
The library connects to a SAPL Node Policy Decision Point (PDP) over HTTP, with Server-Sent Events for continuous decisions. It enforces three attributes, #[PreEnforce], #[PostEnforce], and #[StreamEnforce], and wires itself into the Symfony container through a single bundle.
What is SAPL?
SAPL is a policy language and Policy Decision Point for attribute-based access control. Policies are written in a dedicated language and evaluated by the PDP, which streams authorization decisions based on subject, action, resource, and environment attributes.
Three core concepts:
- Authorization subscription. A question to the PDP, carrying the subject, the action, the resource, and optional environment.
- Authorization decision. The answer:
PERMIT,DENY,INDETERMINATE,NOT_APPLICABLE, orSUSPEND, optionally with obligations and advice. - Obligations and advice. Conditions attached to a decision. An obligation must be fulfilled or the decision flips to deny. Advice is best-effort.
How sapl/sapl-php Works
The bundle subscribes to the Symfony kernel.controller_arguments event, the last point at which the resolved controller can be wrapped. When a controller or service method carries one of the enforcement attributes, the bundle replaces the call with a wrapper that runs the matching enforcement point around it.
For #[PreEnforce], the wrapper builds an authorization subscription, asks the PDP, and runs the method only on a PERMIT whose obligations are all handled. For #[PostEnforce], the method runs first and its return value becomes part of the subscription, so the policy can decide on the actual result. For #[StreamEnforce], the method returns a stream and the wrapper enforces a live decision stream over it.
Enforcement is fail-closed. A DENY, an INDETERMINATE, a NOT_APPLICABLE, or an unhandled obligation results in an AccessDeniedException, which Symfony renders as 403.
Installation
composer require sapl/sapl-php
The library requires PHP 8.3 or later and Symfony 7.3. It uses ReactPHP (react/http, react/async) for the streaming transport. The Doctrine query-rewriting shims are optional and pull in doctrine/orm or doctrine/mongodb-odm only if you use them.
Register the bundle in config/bundles.php (Symfony Flex does this automatically):
return [
// ...
Sapl\Symfony\SaplBundle::class => ['all' => true],
];
Configuration
Configuration lives under the sapl key. Only pdp.base_url is required.
# config/packages/sapl.yaml
sapl:
pdp:
base_url: '%env(SAPL_PDP_BASE_URL)%' # required, the SAPL Node URL
token: null # bearer token for the PDP (optional)
username: null # HTTP Basic username (optional)
secret: null # HTTP Basic password (optional)
timeout: 5.0 # request timeout in seconds
verify_peer: true # verify the PDP TLS certificate
# .env
SAPL_PDP_BASE_URL=https://localhost:8443
Plain http base URLs are restricted to loopback hosts. Use https for any non-local PDP.
Authenticating to the PDP
The client presents one of three credentials to the SAPL Node, matching the node’s configured authentication:
- None. Leave
token,username, andsecretunset. The node must allow unauthenticated access. - Bearer token. Set
token. The client sendsAuthorization: Bearer <token>. - HTTP Basic. Set
usernameandsecret. The client sendsAuthorization: Basic ....
For rotating credentials, implement Sapl\Pdp\Auth\TokenProvider and register it as the sapl.token_provider service. The client calls accessToken() for each request and invalidate() on a 401 or 403, so a refreshed token is fetched on the next call.
interface TokenProvider
{
public function accessToken(): string;
public function invalidate(): void;
}
Enforcement Attributes
All three attributes target a method or a class and accept the same subscription fields. Each field is a literal value or a Sapl\Symfony\Expression, evaluated against a context of subject, args (the method arguments by name), and request. #[PostEnforce] additionally exposes returnValue.
When a field is omitted, sensible defaults apply: the subject comes from the SubjectResolver, the action from the method name, and the resource from the class and method.
#[PreEnforce]
Enforces before the method runs. On a non-PERMIT decision or an unhandled obligation, the method never executes.
use Sapl\Symfony\PreEnforce;
#[Route('/api/patient/{id}', methods: ['GET'])]
#[PreEnforce(action: 'readPatient', resource: 'patient')]
public function patient(string $id): array
{
return Patients::byId($id) ?? throw new NotFoundHttpException('Patient not found');
}
#[PostEnforce]
Runs the method first, then decides with the return value in scope. Use it when the policy depends on the actual resource, for example row-level checks on a fetched record.
use Sapl\Symfony\PostEnforce;
use Sapl\Symfony\Expression;
#[PostEnforce(
action: 'getPatientDetail',
resource: new Expression("{'type': 'patientDetail', 'data': returnValue}"),
)]
public function getPatientDetail(string $id): ?array
{
return Patients::byId($id);
}
#[StreamEnforce]
Enforces a continuous decision over a streaming response. See Streaming Enforcement below. In addition to the subscription fields it accepts two flags, signalTransitions and pauseRapDuringSuspend.
Subject Resolution
When an attribute has no explicit subject, the subject is taken from the Sapl\Symfony\SubjectResolver service. The default TokenStorageSubjectResolver reads the current Symfony security token. An authenticated user becomes { "username": <identifier>, "roles": [...] }, and an unauthenticated request becomes the string "anonymous". Override the behaviour by aliasing SubjectResolver to your own implementation.
Constraint Handlers
A decision can carry obligations and advice. The bundle resolves them to handlers through providers implementing Sapl\Pep\Constraints\ConstraintHandlerProvider:
interface ConstraintHandlerProvider
{
/**
* @param list<SignalKind> $supportedSignals
* @return list<ScopedHandler>
*/
public function getConstraintHandlers(mixed $constraint, array $supportedSignals): array;
}
Tag a provider service with sapl.constraint_handler_provider and it is picked up automatically. An obligation with no matching handler fails closed and the decision is denied. Advice with no handler is ignored.
The built-in ContentFilteringProvider handles the filterJsonContent obligation, redacting the response with blacken, replace, or delete actions on JSON paths before it is returned.
Query Rewriting
The bundle ships two data-layer shims that narrow the rows an enforced method reads, rather than filtering them in memory. The Doctrine ORM filter honours the sql:queryRewriting obligation and the Doctrine ODM filter honours mongo:queryRewriting. Both are PreEnforce-only and fail closed.
See Query Rewriting for the obligation schema, the shared semantics, and the Doctrine setup, including the columns-against-entities caveat.
#[Route('/sql/patients', methods: ['GET'])]
#[PreEnforce(subject: new Expression('subject["username"]'), action: 'readSqlPatients', resource: 'patients')]
public function patients(): array
{
// The policy attaches a sql:queryRewriting obligation that narrows the rows
// this repository call returns at the database, per subject.
return $this->entityManager->getRepository(PatientRecord::class)->findBy([], ['id' => 'ASC']);
}
Streaming Enforcement
A #[StreamEnforce] method returns a ReactPHP React\Stream\ReadableStreamInterface. The bundle opens a decision stream from the PDP and gates the method’s item stream on it.
use Sapl\Symfony\StreamEnforce;
use React\Stream\ReadableStreamInterface;
#[Route('/api/streaming/heartbeat/till-denied', methods: ['GET'])]
#[StreamEnforce(action: 'stream:terminate', resource: 'heartbeat')]
public function tillDenied(): ReadableStreamInterface
{
return Heartbeat::source();
}
The decision verb steers the stream:
- PERMIT lets items flow.
- DENY, INDETERMINATE, or NOT_APPLICABLE terminates the stream.
- SUSPEND pauses output. The next PERMIT resumes it.
Two flags tune the suspend behaviour:
pauseRapDuringSuspend(defaultfalse). Whentrue, the underlying item stream is paused during a suspend, so items produced while suspended are held and delivered on resume rather than dropped.signalTransitions(defaultfalse). Whentrue, the output stream also emits suspend and resume boundary signals, so a subscriber can react to the transitions.
#[StreamEnforce(action: 'stream:suspend', resource: 'heartbeat', pauseRapDuringSuspend: true, signalTransitions: true)]
public function observedSuspending(): ReadableStreamInterface
{
return Heartbeat::source();
}
Manual PDP Access
For full control, inject Sapl\Pdp\PolicyDecisionPoint (the bundle binds it to the HttpPdpClient) and call it directly:
decideOnce(AuthorizationSubscription): AuthorizationDecision. One-shot decision. Fails closed toINDETERMINATEon any transport error.multiDecideAllOnce(MultiAuthorizationSubscription): MultiAuthorizationDecision. One-shot batch.decide(AuthorizationSubscription): ReadableStreamInterface. Continuous stream, reconnecting with backoff.multiDecide(...)andmultiDecideAll(...). Streaming multi-subscription variants.
Transport and Resilience
The PHP SDK talks to the PDP over HTTP only. One-shot calls use the Symfony HTTP client. Streaming calls use ReactPHP and consume Server-Sent Events, with no response timeout so a long-lived decision stream stays open.
One-shot calls do not retry. They fail closed to INDETERMINATE so a transport error never leaks into a PERMIT. Streaming calls reconnect on failure with bounded exponential backoff, controlled by retryBaseDelaySeconds and retryMaxDelaySeconds on the client options.