stave.dev

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 —
One attribute here is not Zero's

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
}
Why This Order?

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.

The order is enforced

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

Presence Only

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

Do not add new ones

#[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:

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;
    }
}
Custom Attribute Requirements

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

Collect Class Attributes
→
Collect Method Attributes
→
Run handler() in order
→
All pass?
→
Execute Endpoint
  1. 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 — except RequirePermission, which adds to it.
  2. Anything outside the Zero\Core\Attribute namespace is filtered out, so a PHP built-in on the class is left alone.
  3. Each remaining attribute's handler() runs in declaration order.
  4. A handler that refuses throws HTTPError. That propagates to Application::run(), which calls renderError() on the live module — so the error page keeps the module's own frame.
  5. If every handler passes, the endpoint method is called.
  6. 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.