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 ($_SESSION['auth_level'] < 5) { http_response_code(401); exit; }
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
    // ...finally, the actual logic
}

// Do this:
#[RequireLogin]
#[RequireAuthLevel(5)]
#[AllowedMethods('POST')]
public function deleteUser($id) {
    // All checks already passed — just write your logic
}

Attributes are checked by Application::checkForAttributes() before the endpoint method is called. Each attribute's handler() method runs in declaration order. If any handler returns an Error object, execution stops and the error is returned.

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 Require minimum auth level (1–10) 401
RequirePermission Require specific named permission(s) Redirect
AuditLog Log endpoint access to event table

All attributes live in the \Zero\Core\Attribute namespace:

use Zero\Core\Attribute\{
    AllowedMethods,
    RequiredParams,
    Sanitize,
    Validate,
    RequireLogin,
    RequireAuthLevel,
    RequirePermission,
    AuditLog
};

Recommended Stacking Order

When combining multiple attributes, order matters. Use this sequence to ensure users receive appropriate responses at each stage:

#[RequireLogin]             // 1. Ensure user is logged in (redirect to login page)
#[RequireAuthLevel(5)]      // 2. Check auth level (401 if insufficient)
#[RequirePermission('api')] // 3. Check permission (redirect to access request page)
#[AuditLog('action_type')]  // 4. Log the access attempt
#[AllowedMethods('POST')]   // 5. Restrict HTTP method
#[RequiredParams(['data'])] // 6. Validate required parameters
#[Sanitize(['data'])]       // 7. Sanitize input
public function secureEndpoint() {
    // All checks passed, access logged, params sanitized
}
Why This Order?

RequireLogin bounces anonymous users to the login page. RequireAuthLevel returns 401 for logged-in users without sufficient access. RequirePermission redirects to the access request page, since the user has the auth level but needs a specific permission. Check auth before logging or validating request data. Sanitize after confirming parameters exist.

Class-Level vs. Method-Level

Attributes can be applied at the class level (affecting all endpoints) or at the method level (affecting a specific endpoint). Both are merged and executed:

// Class-level: every endpoint requires login
#[RequireLogin]
class AdminPanel extends \Zero\Core\Module {

    // Inherits #[RequireLogin] from class
    public function dashboard() { /* ... */ }

    // Gets #[RequireLogin] from class PLUS its own attributes
    #[RequireAuthLevel(10)]
    #[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

Requires a minimum authentication level. The user's level is read from $_SESSION['auth_level'] and compared to the required minimum. Higher numbers mean higher privileges.

#[RequireAuthLevel(5)]
public function moderatorPanel() {
    // auth_level >= 5 required
}

#[RequireAuthLevel(10)]
public function superAdmin() {
    // Only the highest level
}

Returns HTTP 401 Unauthorized if the user's level is insufficient. Common level conventions:

Level Role
1Standard User
5Moderator
10Administrator

RequirePermission

Requires specific named permissions. More granular than auth levels — permissions are stored per-user in the user_settings table. Accepts a single permission string or an array (user must have all listed permissions).

// 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 user lacks a required permission, they are redirected to /access-request/permission?p={permission} where they can submit a request for access. This is why RequirePermission comes after RequireAuthLevel — the user already has a sufficient base level and just needs a specific capability.

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,
    RequireAuthLevel,
    RequirePermission,
    AuditLog,
    AllowedMethods,
    RequiredParams,
    Sanitize
};

class Admin extends \Zero\Core\Module {

    #[RequireLogin]
    #[RequireAuthLevel(5)]
    #[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. Both class-level and method-level attributes are collected and merged
  2. Each attribute's handler() method executes in declaration order
  3. If a handler returns true, the next attribute is checked
  4. If a handler returns an Error object, execution stops and the error is displayed
  5. If all handlers pass, the endpoint method is called