TruCMS Architecture Overview

How a request travels through TruCMS, which services exist, and where the extension points sit. Every later chapter assumes this mental model.

Boot sequence

Laravel 13 bootstraps from bootstrap/app.php:

Middleware aliases you can use on routes

AliasClassPurpose
auth, guestapp Authenticate, RedirectIfAuthenticatedSession authentication
role, permission, role_or_permissionSpatie middlewareRBAC route guards
AdminThemeApp\Http\Middleware\AdminThemeActivates the admin theme and fires AdminBooting
FrontThemeApp\Http\Middleware\FrontThemeActivates the front theme, shares the dynamic CSS URL, locale and text direction
2faPragmaRX\Google2FALaravel\MiddlewareTOTP gate for admin routes
RedirectIfInstalledapp middlewareInstaller-only routes
doCache / doNotCacheSpatie CacheResponse / DoNotCacheResponseResponse-cache opt in / out

Service providers

Listed in bootstrap/providers.php:

ProviderResponsibility
AppServiceProviderTheme-option singletons, binding-source registry, HeaderComposer, the super-admin Gate::before, the page-builder-ai rate limiter, response-cache invalidation hooks, HTTPS forcing
MetaTagsServiceProviderSEO head tags (Meta::setMeta() macro), favicon, webmaster verification, Google Tag Manager, Open Graph and Twitter Card packages
DotartisanServiceProviderRegisters the manager singletons (menumanager, settingsmanager, dashboard, shortcodes, app-pages, rolesmanager…), default dashboard rows, the @dashboard directive, custom validators, Module:: macros, core AppPages and core shortcodes
WidgetsServiceProviderRegisters the built-in front widgets and, after every other provider has booted, loads persisted widget areas and instances
LaravelModulesServiceProviderBoots every enabled module's own service provider
FrontendRoutingServiceProviderRegisters the catch-all {slug} route (resolve.show) after everything else so reserved prefixes never collide

Module providers boot inside LaravelModulesServiceProvider, so anything that must see all registrations (widgets, binding-source watchers, reserved slugs) is deferred with $app->booted(...).

The three request families

Admin requests

GET /admin/pages
  -> web group
  -> routes/web.php : Route::middleware([AdminTheme])->as('admin.')->prefix('admin')
  -> routes/admin.php : auth, verified, 2fa  ->  ->can('manage page')  ->  PageController@index
  -> view('pages.index')  resolved in resources/themes/admin/pages/index.blade.php

AdminTheme::handle() does five things, in order:

  1. Selects the admin theme (setting('admin_theme'), default admin).
  2. AdminAsideMenu::register() — registers the core sidebar entries for the current user's permissions into MenuManager.
  3. RegisterSettings::register() — registers all core settings sections into SettingsManager.
  4. On admin.menus* routes, BuilderMenu fills the menu-builder sources.
  5. Dispatches App\Events\AdminBooting. Every module listens to it to add its own sidebar items, settings sections and menu-builder sources.

The most important hook

AdminBooting fires on every admin request, after the core menu and settings are registered. A plugin that listens to it can add sidebar entries, settings sections and menu-builder sources without touching core files. Module admin routes use the same AdminTheme middleware, so your pages also receive these registrations.

Public requests

GET /about
  -> web group
  -> catch-all route 'resolve.show'  (FrontendRoutingServiceProvider)
  -> FrontTheme, TwoFactorMiddleware, doCache
  -> ResolvePathController@show -> SlugResolver
  -> slugs row -> sluggable model -> resolvedSlugRenderer() -> renderer->render()
  -> view('pages.show')   (resources/themes/echo/pages/show.blade.php)

Fixed routes (/, /contact, /blog, /category/{category}, /tag/{tag}, auth pages and the /user/* account area) are declared in routes/front.php, routes/app.php and routes/auth.php. Anything else at the first URL segment is treated as a slug (pages, posts, booking services, shop products). That is why modules reserve their own segments with public_paths in module.json. See Page types and public URLs.

Public routes are wrapped in Route::localized(...) where translation is supported, so locale prefixes such as /ar/about are handled by codezero/laravel-localized-routes.

Module requests

A module's RouteServiceProvider wraps its route files in the same theme middleware:

Modules/Booking/app/Providers/RouteServiceProvider.php

Route::middleware(['web', 'auth', AdminTheme::class])
    ->as('admin.booking.')
    ->prefix(config('dotartisan.admin_prefix', 'admin') . '/bookings')
    ->group(module_path($this->name, '/routes/admin.php'));

Core routes/*.php files neverrequire module routes. Disabling a module removes its routes, menu entries, widgets and scheduled tasks; its database tables stay.

Rendering model: themes and views

TruCMS ships three themes in resources/themes/:

ThemePurpose
adminCoreUI-based admin panel, GrapesJS editor chrome, settings and dashboard components
echoThe public front end: layouts, header and footer presets, the blog "publication" design, widgets, emails, auth screens, the logged-in visitor area and the page-template library
defaultParent theme with shared static assets (images, CKEditor build). Every theme extends it

igaster/laravel-theme adds the active theme's folder to Laravel's view paths, so view('layouts.app-layout') resolves to resources/themes/echo/layouts/app-layout.blade.php and a child theme can override any view by providing the same relative path.

Module views are namespaced (booking::admin.dashboard). The theme view finder searches for an override before the module's own folder, in resources/themes/{active theme}/vendor/{namespace}/… — for example resources/themes/echo/vendor/booking/admin/dashboard.blade.php. Views published to resources/views/modules/{alias} are mapped onto {theme}/modules/{alias}. This is how a theme restyles a module without editing it.

The hook layer: registries and facades

DotartisanServiceProvider binds string-keyed singletons that are exposed through facades. These are the extension points for modules and themes:

Facade (App\Facades\…)Container keyClassWhat you register
MenuManagermenumanagerServices\MenuManagerAdmin sidebar entries (MenuItems)
MenuBuildermenubuildermanagerServices\MenuBuilderManagerLink sources in the admin Menus builder
SettingsManagersettingsmanagerServices\Settings\SettingsSettings sections → pages → fields
DashboarddashboardServices\Dashboard\DashboardRegistryDashboard rows and widgets for any named dashboard
WidgetsSeoWidgetServices\WidgetManagerFront widget classes selectable in Appearance → Widgets
ShortcodeshortcodesServices\PageBuilder\ShortcodeRegistryPage-builder shortcodes, categories and asset markers
AppPagesapp-pagesServices\AppPages\PageRegistryNamed page types ("Shop page", "Contact page") that an admin maps to a CMS page
RolesrolesmanagerServices\RoleManagerTeam (workspace) roles and API-token abilities
—BindingSourceRegistry::classServices\PageBuilder\Bindings\BindingSourceRegistryData-bound section sources
—PageBuilderAssetRegistry::class (scoped)Services\PageBuilder\Assets\PageBuilderAssetRegistryPer-request Vite, CSS and JS assets for page-builder content
Meta—Butschster\Head\…\Meta<head> tags; TruCMS adds setMeta($model) and setFont([...]) macros

Registries are plain objects rebuilt on every request. Whatever you register must therefore run in every request that needs it. That is why menu and settings registrations happen inside an AdminBooting listener, while shortcodes, widgets and binding sources are registered in the provider's boot().

Persistence model

WhatWhereNotes
Site configurationsettings table (Akaunting)Read with the global setting('key', $default); write with Setting::set() then Setting::save()
Machine/env configuration.envEdited by settings screens through DotenvEditor; saving runs php artisan dotartisan:optimize
Theme optionstheme_options setting, scoped per themeRead with theme_option('key', $default); defaults come from the theme's theme.json
UploadsSpatie Media Library (media table) and the public diskHasMedia models; settings file fields store a path in settings
ContentEloquent models in app/Models and Modules/*/app/ModelsBaseModel adds cache invalidation
SlugsGlobal slugs tableDual-written with legacy slug columns by HasSlug
Module statemodules_statuses.jsonEnabled/disabled map used by nwidart's file activator

Caching layers

  1. Response cache stores whole public HTML pages for guests (and per user for signed-in users). Opt in per route with doCache; never cache state-changing pages.
  2. Model and key caches via the ClearCache trait on BaseModel, plus manual Cache::remember for menus, languages, countries and widget areas.
  3. Compiled theme CSS written to storage/app/public/css/themes/{theme}/app.css when theme options change.
  4. Laravel caches (config, route, view); dotartisan:optimize and dotartisan:cache-views are the project's "safe" variants.

See Caching and performance.

Background work

Cross-cutting guards

GuardEffect
dotartisan.installedFalse → everything redirects to the installer; several registrations skip themselves
dotartisan.demoEnables the demo write guard for [email protected] and [email protected]by route-name pattern (admin.*.store, admin.*.update, …) and the public /demo/* showcase
Super adminThe user holding the role whose id is dotartisan.super_admin_role (default 1) passes every Gate check
Two-factor2fa alias on routes/admin.php; TwoFactorMiddleware on front and user routes; both delegate to App\Services\Authenticator

Tip for plugin authors

Because the demo guard matches route names, give admin routes conventional names (admin.myplugin.items.store, .update, .destroy) so they are protected on demo installs automatically.