hostkurd / flocms-core
Core runtime for Flo Framework: application services, HTTP primitives, dependency injection, and modular architecture.
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
- ext-pdo_mysql: Required by the bundled MySQL connection and module state/migration implementations.
- hostkurd/flocms-api: Adds the explicit API router, middleware pipeline, JSON exception handling, CORS, and rate limiting.
Provides
None
Conflicts
None
Replaces
None
- dev-main / 2.9.x-dev
- v2.9.0
- v2.8.0
- v2.7.0
- v2.6.0
- v2.5.0
- v2.4.0
- v2.3.2
- v2.3.1
- v2.3.0
- v2.2.3
- v2.2.2
- v2.2.1
- v2.2.0
- v2.1.0
- v2.0.0
- v1.0.7
- v1.0.6
- v1.0.5
- v1.0.4
- v1.0.3
- v1.0.2
- v1.0.1
- v1.0.0
- dev-feat/core-2.9-sqlite
- dev-feat/core-2.8-security-tools
- dev-feat/core-2.7-held-sessions
- dev-feat/core-2.6-fallback
- dev-feat/guest-admin-actions
- dev-feat/roles-loader
- dev-feature/controller-responses
- dev-fix/admin-session-logout
- dev-feature/controller-resolvers
- dev-fix/validator-max-empty
This package is auto-updated.
Last update: 2026-10-23 22:06:42 UTC
README
hostkurd/flocms-core is the application foundation for Flo Framework.
Version 2 keeps shared HTTP primitives in core and moves all module
discovery, validation, lifecycle, state, caching, and migrations into this
package.
API applications should additionally install hostkurd/flocms-api. The API
package depends on core; core never depends on the API package.
Explicit controller responses
Controllers may return a FloCMS\Core\Http\Response instead of a view path:
public function results(): \FloCMS\Core\Http\Response { return \FloCMS\Core\Http\Response::html('<p>Updated results</p>') ->header('Cache-Control', 'no-store'); }
Core sends the explicit response without adding a layout. The normal CSRF, authentication, and action-permission checks still run. Existing controllers returning view paths keep their behavior. HEAD responses send status and headers without a body. This supports HTML fragments and JSON in optional packages without making Core depend on those packages. Available from 2.3.2.
Package boundary
Core owns:
- the application runtime and existing MVC compatibility layer;
Request,Response, configuration, database, logging, and security helpers;- a small reflection-based dependency-injection container;
- module manifests, dependency validation, compiled discovery, autoloading, providers, enable/disable state, audit records, and migrations.
For compatibility with Flo 1.x applications, short names declared in
controllers and models are class-mapped directly from a module's Http/
and Models/ directories. Generated adapter files in the application's root
controllers/ and models/ directories are no longer required. New modules
should prefer their own PSR-4 namespace through the autoload manifest key.
An application owns only:
modules/for project modules;api/for application API routes/controllers when the optional API package is installed;- configuration, public entry points, templates, and storage.
No module directory is scanned during a production HTTP request. Run a sync
command during deployment to compile the registry into
storage/cache/modules.php.
Module manifest
Each project module is placed at modules/<module-id>/module.php:
<?php return [ 'id' => 'news', 'name' => 'News', 'description' => 'Company news and categories.', 'version' => '1.0.0', 'schema_version' => 1, 'kind' => 'optional', 'default_enabled' => true, 'priority' => 100, 'dependencies' => [], 'autoload' => [ 'App\\Modules\\News\\' => 'src/', ], 'providers' => [ App\Modules\News\NewsServiceProvider::class, ], 'routes' => [ 'api' => 'routes/api.php', ], 'migrations' => [ 'database/migrations/001_create_news.php', ], ];
Paths in a manifest are resolved inside that module. Traversal outside the module is rejected.
Bootstrap
use FloCMS\Core\Modules\ModulePaths; use FloCMS\Core\Modules\ModuleSystem; $paths = ModulePaths::fromRoot(ROOT); ModuleSystem::configure( paths: $paths, database: \FloCMS\Core\App::db(), allowRuntimeDiscovery: false, ); ModuleSystem::autoloader()->register(); ModuleSystem::bootstrapper()->boot();
During deployment:
$result = ModuleSystem::synchronizer()->sync();
The migration runner uses a database advisory lock on MySQL/MariaDB so two deployments cannot migrate modules concurrently. Applied migrations are checksummed and must never be edited; create a new migration instead.
Database errors and lazy connections (2.2+)
Models connect on first use of $this->db, so pages that never query the
database work without one. When the connection fails, App::db() throws
DatabaseConnectionException; the error handler renders
templates/<template>/errors/nodbserver.html (server unreachable, HTTP 503) or
dberror.html (access denied, unknown database, not configured). Templates
receive $message, $errorCode, $status and, in debug mode only, $detail
(the driver message). App::dbStatus() reports the state without throwing:
$status = App::dbStatus(); // ['configured' => true, 'connected' => false, 'reason' => 'unknown_database', // 'code' => 1049, 'message' => 'Database not found.', 'detail' => '...']
SQLite (2.9+)
Websites can use SQLite as well as MySQL/MariaDB, like php flo:
DB_TYPE=sqlite DB_NAME=storage/database.sqlite ; relative to the project, or a full path
SQLite has no user or password, so DB_NAME alone makes the database
configured. App::dbDriver() returns 'sqlite' or 'mysql', from config
db.type when the site sets it and from DB_TYPE in .env otherwise. The file
is never created by a web request: when it is missing, App::db() throws
DatabaseConnectionException with the reason missing_file ("Check DB_NAME in
.env, or run php flo migrate to create it"). Connections turn on foreign keys
and wait up to 5 seconds for a locked file. Keep the file out of public/.
Query builder (2.2+)
table() starts a new, independent query:
$db = App::db(); // status = ? AND (title LIKE ? OR city LIKE ?) $page = $db->table('listings') ->where('status', '=', 'active') ->where(fn ($q) => $q->where('title', 'LIKE', "%{$term}%") ->orWhere('city', 'LIKE', "%{$term}%")) ->whereBetween('price', [$min, $max]) ->whereRaw('MATCH(title, body) AGAINST(? IN BOOLEAN MODE)', [$words]) ->orderBy('created_at', 'DESC') ->paginate($pageNumber, 20); $page['data']; // rows (objects) $page['pagination']; // same keys as Model::pagingArray() // Distance search (MySQL 5.7+/MariaDB 10.5+): bindings are required $near = $db->table('listings') ->select('id, title') ->selectRaw('ST_Distance_Sphere(location, POINT(?, ?)) AS meters', [$lng, $lat]) ->whereRaw('ST_Distance_Sphere(location, POINT(?, ?)) <= ?', [$lng, $lat, 5000]) ->orderByRaw('meters ASC') ->get();
Raw SQL (whereRaw, orWhereRaw, havingRaw, selectRaw, orderByRaw)
must use ? placeholders with one binding each; never concatenate input into
it. A literal ? inside a quoted string counts as a placeholder, so bind such
values too. toSql() returns the SQL and bindings without running the query.
Controller actions
A URL like /{lang}/{route}/{controller}/{action}/{params} calls the action
method on the controller, with the route's prefix (admin_ under /admin).
Only public, non-static methods declared on your controller (or on your own
base controller) are actions. Methods inherited from FloCMS\Core\Controller,
protected, private and static methods, the constructor and names starting with
__ answer 404, so keep helpers protected or private.
Controllers from packages (2.3+)
The controller segment maps to FloCMS\Controllers\<Name>Controller in your
application. When that class doesn't exist, packages can supply one with a
resolver. It receives the controller segment and the route ('default',
'admin', ...) and returns a controller class, or null to pass:
use FloCMS\Core\App; App::resolveControllersUsing(static function (string $controller, string $route): ?string { return $route === 'admin' && in_array($controller, ['properties', 'agents'], true) ? \Vendor\Admin\ResourceController::class : null; });
/admin/properties/edit/5 then calls ResourceController::admin_edit() with
['5'] as its parameters; App::getRouter()->getController() is
'properties'.
- Application controllers always win: resolvers are only asked when your
controllers/has no matching class. - Resolvers are asked in registration order; the first class returned is used.
- A returned value that isn't an instantiable
FloCMS\Core\Controllersubclass answers 404. - Resolvers run after the admin login check, and the usual rules apply to the
resolved controller: only its declared public actions are callable, and
$actionPermissionsis enforced. - Register resolvers before
App::run(), for example inconfig/config.phpor a module's provider.App::clearControllerResolvers()removes them all.
Pages without a controller (2.6+)
A package can answer public URLs that no controller answers, for example pages it keeps in the database:
use FloCMS\Core\App; use FloCMS\Core\Http\{Request, Response}; App::fallbackUsing(static function (string $path, Request $request): ?Response { $page = Pages::find($path); // "about", "services/web-design", '' for the home page return $page === null ? null : Response::html($page->render()); });
- Asked only on the default route (never for
admin,loginorapiURLs), after CSRF and the maintenance page, when the URL would otherwise answer 404: no controller of that name (the application's or a resolver's), no such action, or a segment that cannot name a controller or action (such asweb-designin second place). - Application controllers and resolvers always win.
$pathis the path after the language and route, decoded, in the case it was requested.App::getRouter()->getPath()gives the same.- Fallbacks are asked in order; return a
Response, ornullto pass. When none answers the request is a 404. Returning anything else throws. - The response is sent as it is: render the site's layout yourself.
App::clearFallbacks()removes them all.
Admin pages for guests (2.5+)
Under the admin route, a visitor who isn't signed in only reaches
users/admin_login; every other admin action redirects to the login page.
A package or the site can open a few more, for example a "forgot password"
page:
use FloCMS\Core\App; App::allowGuestAdminActions('password', ['admin_forgot', 'admin_reset'], \Vendor\Admin\PasswordController::class);
/admin/password/forgot and /admin/password/reset/<token> now run for
guests; /admin/password/anything-else still redirects.
- Pass the class that serves the actions (third argument) so guests reach only that class. Without it, whatever controller answers the URL is open to guests, including a site's own controller of the same name; packages should always pass it.
- Names are exact (controller segment and method, compared in lower case);
there are no wildcards. Invalid names throw
InvalidArgumentException. - Only the login check is skipped. CSRF on POST, controller resolvers,
the declared-public-action check and
$actionPermissionsstill apply, so leave these actions out of$actionPermissions(or map them tonull). - Signed-in users reach them too; send them elsewhere in the action if that makes more sense.
App::isGuestAdminAction($controller, $method)tells whether an action is open;App::clearGuestAdminActions()removes them all.
Session checks (2.5+)
Auth::refresh() reloads the signed-in user through auth.user_loader on
admin requests and ends the session of a deleted or suspended user. A package
can add its own checks on the loaded user; returning false ends the session
the same way:
use FloCMS\Core\Auth; use FloCMS\Core\Session; // End other sessions after a password change Auth::checkSessionsUsing(static function (array $user): bool { $stamp = substr(hash('sha256', (string) ($user['password'] ?? '')), 0, 16); if (Session::get('password_stamp') === null) { Session::set('password_stamp', $stamp); } return Session::get('password_stamp') === $stamp; });
- Checks run after the status check, in the order they were added, only when
auth.user_loaderis set; withauth.refresh_intervalabove 0, only when the user is reloaded. - A check that throws is logged and ends the session.
Auth::clearSessionChecks()removes them all.
Held sign-ins (2.7+)
A package can hold a fresh sign-in at one admin page until a step is done: a two-factor code, accepting new terms, changing a temporary password. The skeleton's login action needs no change.
use FloCMS\Core\App; use FloCMS\Core\Auth; App::allowGuestAdminActions('two-factor', ['admin_index'], TwoFactorController::class); Auth::holdSessionsUsing(static fn (array $user): ?string => TwoFactor::isOn((int) $user['id']) ? 'two-factor' : null); // In TwoFactorController::admin_index(), once the code is right: $next = Auth::releaseHold(); // null: fully signed in; else the next hold's path // "Use another account": Auth::cancelHold();
- The holds are asked once per sign-in: on the request that signs in (when it
ends, before the session is saved) or the first one after it, on any route,
with the user row from
auth.user_loader. Without a loader, or when the user can't be loaded, the session ends: a hold never lets a sign-in through. - While held, the sign-in keys (
auth.hold_keys, defaultisloggedin,admin_access,role,user_id,username,email,fullname) are parked: front pages and the admin gate see a visitor. Admin requests go to/admin/<path>; other guest admin actions andusers/admin_logoutstill open. The path's action must be a guest admin action, otherwise the session ends (logged). - The step is a guest action, so anyone can open it: check
Auth::heldPath()andAuth::heldUserId()(whose sign-in it is) before doing anything. - The path is
controllerorcontroller/action; the action has no hyphens. releaseHold()restores the sign-in with a new session id, checks the user's status again and asks the next hold.- A hold that throws or returns an invalid path is logged and ends the session.
A held session older than
auth.hold_lifetimeseconds (default 600) ends with a notice (auth.hold_expired). Auth::clearSessionHolds()removes them all.
Rate limits and client IPs (2.8+)
Small security tools every site needs (login throttling, maintenance mode for your own IPs), moved here from flocms-api so a site without the API has them too:
use FloCMS\Core\RateLimit\FileRateLimiter; use FloCMS\Core\Security\{ClientIpResolver, IpMatcher}; // The visitor's address; X-Forwarded-For is only read from these proxies. $ip = (new ClientIpResolver(['10.0.0.0/8']))->resolve($request); // 5 tries per 15 minutes per address. $result = (new FileRateLimiter(ROOT . '/storage/ratelimit'))->hit('login:' . $ip, 5, 900); if (!$result->allowed) { /* wait $result->retryAfter seconds */ } IpMatcher::matchesAny($ip, ['203.0.113.7', '2001:db8::/32']);
RateLimiterInterface (hit(), clear()), FileRateLimiter (any host; gc()
removes old files) and ApcuRateLimiter (where APCu is enabled; check
ApcuRateLimiter::isAvailable()). flocms-api 1.3 keeps its old class names
(FloCMS\Api\Security\…, FloCMS\Api\RateLimit\…) as aliases of these.
Roles from packages (2.4+)
Roles and permissions normally come from config/config.php (roles,
permissions, admin_access_roles). A package or the site can supply them at
runtime instead, for example from a database table that an admin page edits:
use FloCMS\Core\Auth; Auth::loadRolesUsing(static function (): ?array { return [ 'roles' => [0 => 'User', 1 => 'Editor', 2 => 'Admin', 3 => 'Super Admin', 4 => 'Author'], 'permissions' => [1 => ['content.*'], 2 => ['content.*', 'users.manage'], 3 => ['*'], 4 => ['content.articles.*']], 'admin_access_roles' => ['1', '2', '3', '4'], 'levels' => [4 => 1], ]; });
- The loader runs once per request, the first time a check needs it
(
App::run()forgets the previous result, so long-running workers ask again on each request).Auth::forgetLoadedRoles()makes the next check ask again (after saving roles in the same request);Auth::clearRolesLoader()removes it. - Each key the loader returns replaces that
Configvalue; keys it leaves out keep the config values. Returningnullmeans "use the config". - A loader that throws, or returns something other than an array or
null, is logged once and ignored for the request: the config roles apply, so a broken store never locks everybody out. - Permission checks made inside the loader see the config roles.
Auth::roles()returns whatever names the loader gives (flocms-admin gives them in the current panel language), not lang keys.
Everything reads through the loader: Auth::can(), Auth::refresh() (which
sets admin_access on each admin request), Auth::canAssignRole() and
Auth::canManageUser(), plus these helpers:
| Method | Returns |
|---|---|
Auth::roles() |
role number ⇒ name |
Auth::permissionMap() |
role number ⇒ permission patterns, or null (legacy mode) |
Auth::adminAccessRoles() |
roles that may open /admin, as strings, or null |
Auth::hasAdminAccess($role) |
true/false, or null when nothing is configured |
Auth::level($role) |
the role's rank |
Levels. canAssignRole() (give a role up to your own) and
canManageUser() (manage strictly lower roles) compare levels. A role's level
is its levels entry, or its number when it has none, so sites without
levels behave exactly as before. Levels let a new role rank between existing
ones without renumbering users.role: role 4 above with level 1 ranks with
Editors.
Compatibility
The legacy FloCMS\Core\Api and FloCMS\Core\ApiController classes remain in
core for the 2.x transition. New APIs should use hostkurd/flocms-api; the
legacy classes are not the basis of the new router.
Upgrading
2.8 → 2.9
No changes needed. MySQL sites work as before. A site with DB_TYPE=sqlite in
.env now uses SQLite for its pages too (before, only php flo did and the
website reported "Database is not configured").
2.7 → 2.8
No changes needed. The rate limiter, client IP and IP matching classes are new in core; code using flocms-api's names keeps working (flocms-api 1.3 aliases them).
2.6 → 2.7
No changes needed. Auth::holdSessionsUsing() and the other held sign-in
methods are new; nothing changes unless a package or the site calls them.
2.5 → 2.6
No changes needed. App::fallbackUsing() and Router::getPath() are new;
nothing changes unless a package or the site calls them. A URL that is not a
valid controller or action name still answers 404 when no fallback is set.
2.4 → 2.5
No changes needed. App::allowGuestAdminActions() and
Auth::checkSessionsUsing() are new; nothing changes unless a package or the
site calls them.
2.3 → 2.4
2.4 is non-breaking. Nothing changes until something calls
Auth::loadRolesUsing(). Applications that compare role numbers themselves for
ranking can switch to Auth::level() so they agree with core once levels are
used. Login code that keeps its own list of admin roles can ask
Auth::hasAdminAccess($role) first and fall back to its list when that returns
null.
2.1 → 2.2
2.2 is non-breaking: sites with a working database behave as before.
- Database errors:
App::db()throwsDatabaseConnectionExceptioninstead of a plainRuntimeException. It extendsRuntimeException, so existingcatchblocks still work. A model without database configuration throwsDatabaseNotConfiguredException(extendsRuntimeException) on first query instead ofExceptionin its constructor. - Session freshness (opt-in): store the user id in the session at login
(
Session::set('user_id', $user['id'])) and configure a loader:Config::set('auth.user_loader', fn (int $id) => (new UsersModel())->getByID($id));. Admin requests then reload role/status from the database. Sessions created before this change have nouser_idand are logged out once. Without a loader nothing changes. The logout message uses theauth.session_endedlanguage key when present. - Query builder:
table()now returns a new builder. Chained code is unaffected; code that calls$db->table('x');and then$db->where(...)as separate statements also keeps working, because calls on the shared instance go to the query its lasttable()started. Conditions added on the shared instance before callingtable()are no longer carried into the query.where()now also accepts aClosure; a subclass overridingwhere(),orWhere(),having()ororHaving()must widen its signature. - Validator:
integernow accepts"5".min/maxon a field withintegerornumericcompare the value; without them they compare the string length, as before but counting characters instead of bytes. Addintegerornumericto fields like prices (integer|min:1000) that relied on the old numeric fallback for empty strings. - Translations:
__('key', ['name' => $name])andLang::get('key', '', ['name' => $name])now replace:name. - Template cache: make sure
views/cache/(or yourview.cache_path) is writable by the web server and ignored by git. Files left there byTemplateEngine::CreateView()can be deleted. Templates are recompiled automatically when they change; delete the directory's contents to force it. - Error pages: database connection errors now use
nodbserver.htmlanddberror.html. Make sure your template has both (or they fall back to500.html), and print$detailif you want the driver message in debug mode.
Running the tests
composer install vendor/bin/phpunit
Database tests run on in-memory SQLite. Tests that need a real MySQL/MariaDB
server are skipped unless FLO_TEST_MYSQL_HOST is set:
FLO_TEST_MYSQL_HOST=127.0.0.1 FLO_TEST_MYSQL_USER=flo FLO_TEST_MYSQL_PASS=flo \ FLO_TEST_MYSQL_NAME=flocms_test vendor/bin/phpunit