Extending FloCMS from a package

Updated October 28, 2026 View as Markdown Edit on GitHub

A package can add pages, admin screens and sign-in steps to a FloCMS site without asking the site to edit its controllers. Core offers a small set of hooks for this. flocms-admin is built on them, and your own packages can use them the same way.

Every hook does nothing until something registers it, so a site that uses none of them behaves exactly as before. The site's own code always wins: its controllers come before any package's, and its routes and checks still apply.

Hook Since core What it lets a package do
App::resolveControllersUsing() 2.3.0 Supply page and admin controllers from the package
Returning a Response from an action 2.3.2 Answer with JSON, a fragment or an empty response, without a view or layout
Auth::loadRolesUsing() 2.4.0 Supply roles and permissions at runtime, for example from a table
App::allowGuestAdminActions() 2.5.0 Open a few admin pages to visitors who are not signed in
Auth::checkSessionsUsing() 2.5.0 End sessions that should no longer be valid
App::fallbackUsing() 2.6.0 Answer public addresses that no controller answers
Auth::holdSessionsUsing() 2.7.0 Hold a fresh sign-in at one page until a step is done

Where to register them

Register hooks once per request, before App::run(). The simplest place is a file that Composer loads for every request, through the package's autoload.files:

{
    "autoload": {
        "psr-4": { "Acme\\Reports\\": "src/" },
        "files": ["bootstrap.php"]
    }
}

The site then only runs composer require; it edits no files, and composer remove takes the hooks away again. A site can also register a hook itself in config/config.php.

Controllers from a package

When a URL names a controller the site doesn't have, core asks each resolver for a class. A resolver gets the controller segment of the URL and the route (default or admin), and returns a class name or null to pass:

use FloCMS\Core\App;

App::resolveControllersUsing(static function (string $controller, string $route): ?string {
    return $route === 'admin' && $controller === 'reports'
        ? \Acme\Reports\ReportsController::class
        : null;
});

/admin/reports now runs Acme\Reports\ReportsController::admin_index(). The rules are the same as for the site's own controllers:

  • A FloCMS\Controllers\ReportsController in the site always wins, and the resolver isn't asked.
  • Resolvers are asked after the admin login check, so admin actions still need a signed-in user with admin access.
  • The class must extend FloCMS\Core\Controller. Anything else answers 404.
  • Only the class's own public actions can be reached, and its $actionPermissions apply. See request lifecycle.

App::clearControllerResolvers() removes every resolver, which tests use between cases.

Returning a response

An action may return a FloCMS\Core\Http\Response instead of a view path. Core sends it as it is, without a view or layout, after the usual login, permission and CSRF checks:

use FloCMS\Core\Http\Response;

public function admin_status(): Response
{
    return Response::json(['status' => 'ok']);
}

Response::html(), Response::text(), Response::json(), Response::make() and Response::noContent() build one. A HEAD request gets the status and headers without the body. Actions that return a view path, or nothing, work as before.

Roles from a table

Auth::loadRolesUsing() gives core the roles and permissions at runtime instead of only from config/config.php. The loader runs once per request and returns any of these keys:

use FloCMS\Core\Auth;

Auth::loadRolesUsing(static function (): ?array {
    return [
        'roles' => [0 => 'User', 1 => 'Editor', 2 => 'Admin', 3 => 'Super Admin', 4 => 'Translator'],
        'permissions' => [
            0 => [],
            1 => ['content.*'],
            2 => ['content.*', 'users.manage', 'users.assign_role', 'settings.*', 'activity.view'],
            3 => ['*'],
            4 => ['content.posts.update'],
        ],
        'admin_access_roles' => [1, 2, 3, 4],
        'levels' => [4 => 1],
    ];
});

Each key it returns replaces that whole setting, so return every role, not only the new one. Keys it leaves out, or a null result, keep the values from config/config.php. A loader that throws or returns something else is logged and ignored, so the configured roles apply.

levels decides who may manage whom: Auth::canAssignRole() and Auth::canManageUser() compare levels, not role numbers. Without a level, a role's level is its number, so existing sites behave as before. Read the current values with Auth::roles(), Auth::permissionMap(), Auth::adminAccessRoles(), Auth::hasAdminAccess($role) and Auth::level($role).

flocms-admin's Roles page uses this hook. See roles and permissions.

Admin pages for guests

Admin actions require a signed-in user. A "forgot password" page has to open without one. App::allowGuestAdminActions() opens the listed actions, the way the login page is open:

App::allowGuestAdminActions('password', ['admin_forgot', 'admin_reset'], \Acme\Auth\PasswordController::class);
  • Use exact controller and method names; there are no wildcards.
  • Pass the class as the third argument. Guests then reach only that class, and a site controller with the same name stays behind the login.
  • CSRF on POST, the action checks and $actionPermissions still apply. Every other admin action still sends guests to the login page.

App::isGuestAdminAction() tells whether an action was opened, and App::clearGuestAdminActions() removes them all.

Ending sessions

When auth.user_loader is set (the skeleton sets it), core reloads the signed-in user on admin requests and ends the session of a suspended user. Auth::checkSessionsUsing() adds your own check on that fresh user row. Return false to end the session:

Auth::checkSessionsUsing(static function (array $user): bool {
    // The password changed since this session signed in
    return ($_SESSION['password_stamp'] ?? null) === substr(hash('sha256', (string) $user['password']), 0, 16);
});

A check that throws is logged and also ends the session. Auth::clearSessionChecks() removes them.

Public addresses from a package

App::fallbackUsing() answers website addresses that no controller answers, such as pages kept in a database (/about, /services/web-design). Each fallback gets the path after the language ('' for the home page) and the request, and returns a Response or null to pass:

use FloCMS\Core\App;
use FloCMS\Core\Http\Request;
use FloCMS\Core\Http\Response;

App::fallbackUsing(static function (string $path, Request $request): ?Response {
    $page = \Acme\Pages\Pages::findByPath($path);

    return $page !== null ? Response::html($page->render()) : null;
});

Fallbacks are asked only on website URLs, never for /admin or the API, after CSRF and maintenance mode, and only when the URL would otherwise answer 404. Site controllers and resolvers always come first. When no fallback answers, the visitor gets the 404 page as before. Returning anything other than a Response or null is an error. App::getRouter()->getPath() gives the same path anywhere in the request; App::clearFallbacks() removes them.

flocms-admin's Pages use this hook.

Sign-in steps

Auth::holdSessionsUsing() holds a fresh sign-in at one admin page until a step is done: a two-factor code, accepting new terms, or changing a temporary password. The hold gets the user row and returns an admin path, or null for no hold:

use FloCMS\Core\App;
use FloCMS\Core\Auth;

Auth::holdSessionsUsing(static fn (array $user): ?string => !empty($user['must_accept_terms']) ? 'terms' : null);
App::allowGuestAdminActions('terms', ['admin_index'], \Acme\Terms\TermsController::class);

While the sign-in is held, the site and the admin area see a visitor who isn't signed in, and every admin request goes to /admin/terms. Only guest admin actions and logout still open, so the step's own page must be a guest admin action, as above. When the step is done, the page calls Auth::releaseHold(), which signs the person in with a new session id and asks the next hold, if any. Auth::cancelHold() signs out instead.

  • The holds are asked once per sign-in, on the request that signs in or the next one.
  • A hold that throws, or returns a path that isn't valid, ends the session. A hold never lets a session through by mistake.
  • A held sign-in older than auth.hold_lifetime seconds (default 600) ends, and the person starts again at the login page.
  • Auth::heldPath() and Auth::heldUserId() tell a page which step and which user it is serving.

flocms-admin's two-factor sign-in and email verification use this hook.

Esc