PHP Attributes
Declarative security, validation, and auditing using PHP 8 attributes — checked automatically before your endpoint executes.
Overview
Zero uses PHP 8 attributes to handle cross-cutting concerns like authentication, method restrictions, and input validation. Instead of writing boilerplate checks at the top of every method, you declare what the endpoint requires:
// Instead of this:
public function deleteUser($id) {
if (!isset($_SESSION['user_id'])) { redirect('/login'); }
if (!userCan('allow.user.delete')) { http_response_code(403); exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
// ...finally, the actual logic
}
// Do this:
#[RequireLogin]
#[RequirePermission('allow.user.delete')]
#[AllowedMethods('POST')]
public function deleteUser($id) {
// All checks already passed — just write your logic
}
Attributes are checked by Application::checkAttributes() before the endpoint method is called. Each attribute's handler() method runs in declaration order; a handler that refuses the request throws an HTTPError, which stops execution and renders the error page on the live module.
checkAttributes() is public and static, and every dispatcher calls it for its own (class, endpoint) pair — Application::run() for top-level modules, Module::__call() for submodules, and AdminResponse::routeToModuleAdmin() for module-admin controllers. Gates are therefore additive down the nesting chain, and an attribute declared inside modules/{Module}/submodule/ fires like any other.
Only attributes in the Zero\Core\Attribute namespace are dispatched. That filter is what lets a class carry a PHP built-in such as #[\AllowDynamicProperties] without the framework trying to call handler() on it.
Available Attributes
| Attribute | Purpose | Error Code |
|---|---|---|
AllowedMethods |
Restrict HTTP methods (GET, POST, PUT, etc.) | 405 |
RequiredParams |
Validate required GET/POST parameters exist | 400 |
Sanitize |
Sanitize input parameters before execution | — |
Validate |
Validate parameter format and values | 400 |
RequireLogin |
Require user to be logged in | Redirect |
RequireAuthLevel |
Shelved — use RequirePermission |
401 |
AllowWithToken |
Establish a session from a Bearer API token |
— |
RestrictIP |
Restrict an endpoint to given IPs or CIDR ranges | 403 |
RequirePermission |
Require specific named permission(s) | Redirect |
AuditLog |
Log endpoint access to event table | — |
This site's core/attribute/ also contains #[PageMeta], which composes the <title> and meta tags of every page you are reading. It is stave.dev's own and is not part of rmvanda/zero.core — clone the framework and you will not find it. It needs no change to the dispatcher: it supplies a handler() like every other attribute and resolves its values by reflection.
All attributes live in the \Zero\Core\Attribute namespace:
use Zero\Core\Attribute\{
RestrictIP,
AllowWithToken,
AllowedMethods,
RequiredParams,
Sanitize,
Validate,
RequireLogin,
RequirePermission,
AuditLog
};
Recommended Stacking Order
When combining multiple attributes, order matters. Use this sequence to ensure users receive appropriate responses at each stage:
#[RestrictIP(['10.0.0.0/8'])] // 1. Network deny, before anything else
#[AllowWithToken] // 2. ESTABLISHES a session from a Bearer token
#[RequireLogin] // 3. Require a logged-in, verified user
#[RequirePermission('allow.api')] // 4. Require the named permission
#[AuditLog('action_type')] // 5. Log the access attempt
#[AllowedMethods('POST')] // 6. Restrict HTTP method
#[RequiredParams(['data'])] // 7. Validate required parameters exist
#[Sanitize(['data'])] // 8. Sanitize input last
public function secureEndpoint() {
// All checks passed, access logged, params sanitized
}
AllowWithToken comes before every gate because it is the one attribute that creates a session rather than checking one — a RequireLogin above it would bounce the Bearer caller before it could authenticate. RequireLogin then bounces anonymous visitors to the login page, or returns 401 to a JSON caller. RequirePermission delegates to RequireLogin first, so it gates on login too, and redirects to the access-request page when the user is signed in but lacks the key. Log and validate only once the caller is known; sanitize after confirming the parameters exist.
core/tests/AttributeOrderTest.php walks every module and endpoint in the codebase and fails the build if a declaration breaks this sequence. It is a test, not a convention.
Class-Level vs. Method-Level
Attributes can be applied at the class level (affecting every endpoint) or at the method level (affecting one endpoint).
A method-level attribute replaces the class-level attribute of the same type. That is deliberate for gates that name a destination or a constraint — a method-level #[RequireLogin('/notes/welcome')] should send that one endpoint somewhere else, not add a second redirect.
#[RequirePermission] is the single exception: it is additive, so a class key and a method key are both required and the effective requirement is their union. Application::isAdditive() names it as the only such type. Under the older replace semantics a class declaring allow.receipt.view and a method declaring allow.receipt.edit meant that endpoint required edit alone — a grant that read narrower was in fact wider.
Class-level attributes are not inherited by a subclass; method-level ones are. And there is no way to drop a class-level gate for one endpoint, so a class with any deliberately public endpoint must gate per method instead — including any class that serves anonymous traffic through __call().
// Class-level: every endpoint requires login
#[RequireLogin]
class AdminPanel extends \Zero\Core\Module {
// Inherits #[RequireLogin] from class
public function dashboard() { /* ... */ }
// Class #[RequireLogin] still applies; RequirePermission is additive,
// AllowedMethods adds a constraint the class did not declare.
#[RequirePermission('allow.user.delete')]
#[AllowedMethods('POST')]
public function deleteUser($id) { /* ... */ }
}
AllowedMethods
Restricts which HTTP methods can reach an endpoint. Accepts a single method string or an array of methods.
// POST only
#[AllowedMethods('POST')]
public function submit() { /* ... */ }
// Multiple methods
#[AllowedMethods(['GET', 'POST'])]
public function flexible() { /* ... */ }
// REST API endpoint
#[AllowedMethods(['PUT', 'PATCH'])]
public function update($id) { /* ... */ }
Returns HTTP 405 Method Not Allowed with a message listing the allowed methods. Methods are normalized to uppercase internally.
RequiredParams
Validates that required parameters exist in $_POST or $_GET before the endpoint runs. Checks all parameters and reports every missing one in the error message.
// Single parameter
#[RequiredParams('username')]
public function lookup() {
$username = $_POST['username']; // Guaranteed to exist
}
// Multiple parameters
#[RequiredParams(['email', 'password'])]
public function register() {
// Both guaranteed present
}
Returns HTTP 400 Bad Request with message: Missing required parameter(s): email, password
RequiredParams validates presence, not format. Empty strings, null, 0, '0', and false are considered missing. For format validation, use the Validate attribute or check in your method.
RequireLogin
Requires the user to be logged in. Takes no parameters. Checks $_SESSION['user_id'] and redirects anonymous users to /user/login?r=y where they see a "Login Required" message.
// Single endpoint
#[RequireLogin]
public function profile() {
// User is guaranteed to be logged in
$userId = $_SESSION['user_id'];
}
// Entire class
#[RequireLogin]
class Dashboard extends \Zero\Core\Module {
// All endpoints require login
}
RequireAuthLevel — shelved
#[RequireAuthLevel] still exists and still works, but it is shelved. Use #[RequirePermission] instead, and never pair the two on the same endpoint.
It requires a minimum integer level, read through User::current()?->authLevel() from the auth.level key in user_settings. Two things make it the odd gate out:
- It is the only auth gate that does not enforce a verified email.
RequirePermissionopens by delegating toRequireLogin, so those two share one predicate;RequireAuthLevelcheckssession_status()and the level, and never touchesusers.verified. It is the one gate an unverified session can pass. auth.levelis a separateuser_settingskey from theallow.*grants, so a level gate sitting beside a permission gate silently vetoes permissions that were deliberately granted.
The full ruling, and the condition for un-shelving it, is in core/attribute/AUTH_GATE_CONSISTENCY.md under “The third gate”.
// Shelved — a level gate beside a permission gate vetoes the grant
#[RequireAuthLevel(5)]
#[RequirePermission('allow.moderate')]
public function moderatorPanel() { /* ... */ }
// Use this instead
#[RequirePermission('allow.moderate')]
public function moderatorPanel() { /* ... */ }
AllowWithToken
Authenticates an API caller from an Authorization: Bearer zk_... header and establishes a session, so the gates below it work exactly as they do for a browser. With no such header it passes through silently and normal session auth applies.
It is the one attribute that creates a session instead of checking one, so it must be declared before every gate. A #[RequireLogin] above it would refuse the Bearer caller before it had a chance to authenticate.
#[AllowWithToken]
#[RequireLogin]
public function apiEndpoint() {
// Reached by a browser session OR a Bearer token
}
RestrictIP
Restricts an endpoint to a list of addresses or CIDR ranges. Accepts a single value or an array, and understands both IPv4 and IPv6.
#[RestrictIP(['10.0.0.0/8', '2001:db8::/32'])]
public function internalOnly() {
// Reachable only from those ranges — 403 otherwise
}
It runs first in the recommended order: a network-level refusal should not depend on who the caller claims to be.
RequirePermission
Requires specific named permissions, stored per user in the user_settings table. Accepts a single permission string or an array (the user must hold all of them). Keys follow an allow.{module}.{verb} convention.
It opens by delegating to RequireLogin, so it gates on login and a verified email as well — you do not need to declare both. It is also the one attribute whose method-level declaration is additive: a class key and a method key are both required.
The grant is a strict whitelist. Only '1', 1 and true grant; every other stored value denies, and any lookup failure denies too. Values are read through to the database on each request and memoised per User instance, so a revoked permission takes effect on the very next request — no logout required.
// Single permission
#[RequirePermission('can_edit_posts')]
public function edit($id) { /* ... */ }
// Multiple permissions (ALL required)
#[RequirePermission(['can_delete_posts', 'can_moderate'])]
public function delete($id) { /* ... */ }
When a browser lacks the permission it is redirected to /access-request/permission?p={permission}, where the visitor can request access. A JSON caller gets 403 instead of the HTML page — the attribute branches on the parsed Accept header, so an XHR receives a status it can act on rather than a login page it cannot parse.
Sanitize
Sanitizes $_POST and $_GET values before your method runs. Supports default sanitization, auto-detected ID fields, built-in presets, filter_var constants, and custom regex patterns.
// Default sanitization (strips special characters) #[Sanitize(['title', 'content'])] // Specific filter #[Sanitize(['email' => FILTER_SANITIZE_EMAIL])] // Built-in preset #[Sanitize(['title' => 'filename'])] // Auto-detect: keys ending in "id" are sanitized as integers #[Sanitize(['user_id', 'post_id'])] // Custom regex (removes non-matching characters) #[Sanitize(['code' => '/[^a-zA-Z0-9]/'])]
Available presets: filename, slug, alphanumeric, alpha, numeric.
AuditLog
Logs endpoint access to the event table for security auditing. Logging never blocks execution — if the log fails, the endpoint still runs.
// Basic logging
#[AuditLog('admin_action')]
public function updatePermission() { /* ... */ }
// Include sanitized request parameters in the log
#[AuditLog('data_export', includeParams: true)]
public function exportData() { /* ... */ }
// Only log when user is authenticated
#[AuditLog('sensitive_view', requireAuth: true)]
public function viewProfile() { /* ... */ }
When includeParams: true, sensitive fields like password, token, api_key, secret, csrf, and authorization are automatically redacted.
Complete Secure Endpoint Example
Combining all the concepts — a secure admin endpoint with full attribute stacking:
<?php
namespace Zero\Module;
use Zero\Core\Attribute\{
RequireLogin,
RequirePermission,
AuditLog,
AllowedMethods,
RequiredParams,
Sanitize
};
class Admin extends \Zero\Core\Module {
#[RequireLogin]
#[RequirePermission('allow.admin')]
#[AuditLog('admin_permission_change', includeParams: true)]
#[AllowedMethods('POST')]
#[RequiredParams(['user_id', 'permission', 'value'])]
#[Sanitize(['user_id', 'permission', 'value'])]
public function updatePermission() {
// By the time we reach this line:
// ✓ User is logged in
// ✓ Auth level is 5+
// ✓ User has 'allow.admin' permission
// ✓ Access has been logged with parameters
// ✓ Request is POST
// ✓ user_id, permission, and value all exist
// ✓ All inputs are sanitized
$userId = $_POST['user_id'];
$permission = $_POST['permission'];
$value = $_POST['value'];
// Just write your business logic
}
}
Creating Custom Attributes
You can create your own attributes by placing a class in core/attribute/ with a handler() method. The handler must return true to continue or an Error object to stop execution:
<?php
namespace Zero\Core\Attribute;
#[\Attribute]
class RateLimit {
public function __construct(
private int $maxRequests = 60,
private int $windowSeconds = 60
) {}
public function handler() {
$key = $_SERVER['REMOTE_ADDR'] . ':' . \Zero\Core\Request::$module;
if ($this->isRateLimited($key)) {
return new \Zero\Core\Error(429, 'Too many requests');
}
return true;
}
private function isRateLimited(string $key): bool {
// Your rate-limiting logic here
return false;
}
}
Namespace must be \Zero\Core\Attribute. File goes in core/attribute/. Must have a handler() method returning true or an Error object. The class needs the #[\Attribute] declaration.
Execution Flow
- Class-level and method-level attributes are collected for this
(class, endpoint)pair. A method-level attribute replaces the class-level one of its own type — exceptRequirePermission, which adds to it. - Anything outside the
Zero\Core\Attributenamespace is filtered out, so a PHP built-in on the class is left alone. - Each remaining attribute's
handler()runs in declaration order. - A handler that refuses throws
HTTPError. That propagates toApplication::run(), which callsrenderError()on the live module — so the error page keeps the module's own frame. - If every handler passes, the endpoint method is called.
- Each nesting level repeats this for its own pair, so a submodule endpoint is gated by its parent's class attributes and then by its own.