Skip to content

🧱 Middleware

A layer that runs before the controller: access checks, authentication, CSRF protection, rate limiting.

📚 What ships with it

AliasWhat it does
authAllows an authenticated site user through; otherwise 401
guestAnonymous only; an authenticated visitor is sent to /
modauthChecks the manager token in the modauth header; otherwise 403
csrfVerifies the CSRF token on POST, PUT, PATCH, DELETE
g-recaptcha-v3Verifies reCAPTCHA v3 on modifying requests
apiRequires a bearer token and a path starting with /api/
throttleRate limiting

Two more come from the framework and have no alias — write them by class name:

NameWhat it does
ApiAuthA REST API token; takes a scope: ApiAuth:cargo.read
SudoOnlyA manager user with sudo, or a member of the administrator group

⚙️ How a name becomes a class

An alias expands to a class name; the namespace is added at run time — PageBlocks\App\Http\Middleware\ first, then Boshnik\PageBlocks\Http\Middleware\. That is why the same bare SudoOnly finds the framework class while Authenticate finds yours.

Do not pass a fully qualified class name

php
// ✅ works
Route::get('profile', 'UserController@profile')->middleware('auth');
Route::get('profile', 'UserController@profile')->middleware('Authenticate');

// ❌ 403: the prefix is added to the FQCN as well
Route::get('profile', 'UserController@profile')->middleware(Authenticate::class);

Pass an FQCN and you get PageBlocks\App\Http\Middleware\PageBlocks\App\Http\Middleware\Authenticate, which does not exist, so the request is denied.

A middleware that is not found denies the request

A typo in the name is an ERROR line in the MODX log and a 403, not a pass. Fail-closed is deliberate here: a guard that is absent must not look like a guard that let you through.

This is the exact opposite of throttle:name, where an undefined limiter allows the request. The difference is the cost of the mistake: a skipped access check exposes data, a skipped rate limit does not.

🧩 Aliases

They live in core/App/bootstrap/app.php — the site layer, untouched by a component upgrade:

php
<?php

return [
    'middleware' => [
        'modauth' => MODAuthenticate::class,
        'csrf' => VerifyCsrfToken::class,
        'g-recaptcha-v3' => VerifyGoogleRecaptchaV3::class,
        'auth' => Authenticate::class,
        'guest' => Guest::class,
        'api' => API::class,
    ]
];

This file must not contain use

There is deliberately no namespace declaration, so Authenticate::class yields the string 'Authenticate' — exactly the name the runtime will prefix.

Add use PageBlocks\App\Http\Middleware\Authenticate; and ::class starts yielding the full name. After prefixing, the class is not found, and every route using that alias starts answering 403.

🛠 Writing your own

A class in core/App/Http/Middleware/, extending Middleware:

php
<?php

namespace PageBlocks\App\Http\Middleware;

use Boshnik\PageBlocks\Http\Request;

class ExampleMiddleware extends Middleware
{
    public function handle(Request $request)
    {
        return true;
    }
}

What handle() may return:

ReturnResult
trueThe route continues
false403
abort($code)Stops with that code
response(...)Your response is sent — redirect('/'), say

Middleware with a parameter

A parameter goes after a colon and reaches setParams():

php
Route::get('api/v1/orders', 'OrderApi@index')->middleware('ApiAuth:orders.read');

The split happens on the first colon. Fully qualified class names contain backslashes but never colons, so the parsing is unambiguous.

🔗 Attaching

To one route

php
Route::get('profile', 'UserController@profile')->middleware('auth');

To a group

php
Route::prefix('account')
    ->middleware(['auth', 'csrf'])
    ->group(function () {
        Route::get('/', 'AccountController@index');
        Route::post('update', 'AccountController@update');
    });

To a whole CRUD set

php
Route::crud('mgr/pb/constructor/blocks', BlockController::class)
    ->middleware(['modauth', 'SudoOnly']);

This attaches to all eleven routes in the batch — see Route::crud().

🚫 Excluding

php
Route::post('webhook', 'WebhookController@handle')->withoutMiddleware('csrf');
php
Route::middleware(['auth', 'csrf'])
    ->withoutMiddleware('csrf')
    ->group(function () {
        Route::post('settings', 'SettingsController@update');
    });

📌 What is attached for you

Every POST, PUT, PATCH and DELETE outside the mgr context automatically gets two:

  • csrf — unless excluded with withoutMiddleware;
  • g-recaptcha-v3 — always.

It turns on by filling in keys, not by a checkbox

The installer registers pageblocks_recaptcha_service, pageblocks_recaptcha_public_key and pageblocks_recaptcha_secret_key empty. An empty secret key is what "off" means, so right after installing reCAPTCHA gets in nobody's way. Fill the keys in and it starts working.

You do not list them yourself. With pageblocks_recaptcha_secret_key empty, the reCAPTCHA check passes everything through, so on a site without keys it changes nothing.

The mgr context is excluded because the manager is guarded by its own token (modauth), and its requests do not carry the site's CSRF token.

© PageBlocks 2019-present