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/ — see the warning below
throttleRate limiting

api is an example, not a guard

core/App/Http/Middleware/API.php ships with its token list hardcoded in the file (secret123, myapitoken). It is a template for your own check, and it lives in the site layer precisely so you can rewrite it. For a real token gate use ApiAuth and the tokens issued in the manager — see REST API.

Three 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
ProOnlyThe paid edition only; the free one answers 402 Payment Required

ProOnly is for you if you are writing a satellite

Inside the component itself it sits on every paid route. The free package does not carry the class at all — along with the paid routes it guarded — so hitting such an address there gives an ordinary 404 rather than a 402. See Free and PRO.

⚙️ 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_public_key and pageblocks_recaptcha_secret_key empty (pageblocks_recaptcha_service arrives filled in as Google reCAPTCHA, and it switches nothing on — it only names the service). 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.

Filling the keys in is not the whole job — the form needs a field

Once the secret key is set, the check demands a g-recaptcha-response value and rejects the request when it is absent. Nothing adds that field for you: put {recaptcha()} in the form, and give the form the pb-form attribute so pbForm can call grecaptcha.execute() and fill it in before sending.

So a form that submits the old-fashioned way, without pbForm, has no way to obtain a v3 token and will fail every POST. Fill the keys in and then walk your forms — this is the mistake that reads as "the site stopped accepting the contact form".

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