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
}
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
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 |
|---|---|
| 1 | Standard User |
| 5 | Moderator |
| 10 | Administrator |
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;
}
}
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
- Both class-level and method-level attributes are collected and merged
- Each attribute's
handler()method executes in declaration order - If a handler returns
true, the next attribute is checked - If a handler returns an
Errorobject, execution stops and the error is displayed - If all handlers pass, the endpoint method is called