Evolution CMS is a community-maintained fork of MODX Evolution, rebuilt on Laravel 12 components. It is a PHP CMS and application framework with a tree-based document/resource model, a template variable (TV) system, plugins, snippets, chunks, and a modular extras ecosystem.
PHP requirement: >= 8.3 (8.4 recommended) · License: GPL-3.0-or-later
/ ← web root (Apache/Nginx document root)
├── index.php ← front-end entry point
├── manager/ ← admin panel entry point (manager/index.php)
├── assets/ ← public uploads, cache, plugins, snippets, templates
│ ├── cache/ ← compiled template/TV cache (git-ignored)
│ ├── files/ ← user-uploaded files
│ ├── images/ ← user-uploaded images
│ ├── plugins/ ← installed plugin JS/CSS assets
│ └── templates/ ← front-end HTML templates
├── themes/ ← manager UI themes (default: demo)
├── views/ ← Blade layouts for the manager UI
├── core/ ← CMS core (NOT web-accessible; block in nginx/Apache)
│ ├── artisan ← CLI entry point: php core/artisan <command>
│ ├── bootstrap.php ← application bootstrap
│ ├── composer.json ← core dependencies (Laravel 12, Pest, etc.)
│ ├── config/ ← Laravel-style config files
│ ├── custom/ ← LOCAL OVERRIDES — copy examples here, never edit originals
│ │ ├── composer.json.example → custom/composer.json (add extra packages)
│ │ ├── define.php.example → custom/define.php (constants override)
│ │ ├── routes.php.example → custom/routes.php (add Laravel routes)
│ │ └── config/ → override any core config key per-subdirectory
│ ├── database/
│ │ ├── migrations/ ← Eloquent migrations (single consolidated migration)
│ │ └── seeders/
│ ├── functions/ ← autoloaded global helpers (helper.php, nodes.php, etc.)
│ ├── lang/ ← locale files (en, ru, de, fr, …)
│ ├── modifiers/ ← output modifier include files (mdf_*.inc.php)
│ ├── src/ ← PSR-4 namespace EvolutionCMS\
│ │ ├── Console/ ← Artisan commands
│ │ ├── Controllers/ ← manager action controllers
│ │ ├── Extensions/ ← Router, Collection extensions
│ │ ├── Facades/
│ │ ├── Legacy/ ← legacy shim classes (Cache, ManagerApi, Modifiers…)
│ │ ├── Middleware/
│ │ ├── Models/ ← Eloquent models (SiteContent, SiteTemplate, SitePlugin…)
│ │ ├── Providers/ ← ~30 Laravel service providers
│ │ ├── Services/ ← thin service layer (AuthServices, ConfigService…)
│ │ └── Support/
│ ├── storage/ ← Laravel storage (logs, cache, compiled views)
│ └── tests/ ← Pest test suite
│ ├── Feature/ ← feature/integration tests
│ └── Unit/ ← unit tests (Console/, CoreTest.php)
├── composer.json ← root composer (thin; delegates to core/)
├── phpstan.neon ← static analysis config (level 0, excludes Legacy/)
└── publiccode.yml ← structured information about version and project
All commits in this project use a bracketed prefix:
type(scope): short description (#issue)
Allowed lowercase types:
add— net-new file, feature, surface, contract, rule, or generated capabilityfeat— net-new user-facing feature or capability where feature wording is clearestfix— bug fix or defect repairupd— update to existing behavior, content, generated output, or workflowref— refactor or restructure without changing intended product meaningdel— explicit removal of obsolete code, docs, links, generated files, or flows
Examples:
add(ex42): add feed source worker (#69)ref(source): split API source transport (#69)fix(ex42): repair source run diagnostics
Use a short concrete scope, write the description in English, do not add a final period,
and append (#issue) only when the commit is tied to an issue.
3.5.x— main stable branch; open PRs against this branch- Feature/fix branches are named descriptively:
fix-rich-text-selection-when-editor-disabled - Do not push directly to
3.5.x; always open a PR
Tests use Pest and live in core/tests/.
# From the core/ directory:
composer test
# Or directly:
cd core && vendor/bin/pestPHPUnit sources cover: factory/, functions/, includes/, modifiers/, src/.
The SQLite test database is core/database/evo-test.sqlite.
# From the repository root:
composer analyze
# Equivalent: php -d xdebug.mode=off vendor/bin/phpstan --memory-limit=512M
# Scope: core/src/ (Legacy/ is excluded)
# Level: 0 (introductory — PRs should not increase error count)The CMS uses a subset of Laravel's Artisan. The entry point is core/artisan.
php core/artisan list # list all commands
php core/artisan cache:clear-full # clear all caches
php core/artisan make:site update # update site files and run pending migrations / updates
php core/artisan package:extras # manage extras/packages
php core/artisan deprecated:list # list deprecated code by semver
php core/artisan translations:sync # sync translation files
php core/artisan doc:list # list content documents
php core/artisan route:list # list registered routes
php core/artisan tpl:list # list registered templates
php core/artisan tv:list # list registered TVsWhen a site was installed from a branch/ref that ends with .x, web and CLI site updates
must target the same .x branch/ref unless the operator explicitly selects another target.
Update migrations, seeders, and data fixes must be trackable or idempotent so repeated
make:site update runs do not break already-updated installations.
Install Composer packages from the core/ directory through the Evolution CMS package installer:
cd core
php artisan package:installrequire vendor/package "*"Do not use composer require directly and do not manually add packages to the root
composer.json. The Artisan command registers the dependency in core/custom/composer.json and
runs Composer in the correct Evolution CMS context.
After installation, run vendor:publish only when the package documents a provider or publish tag,
then apply package migrations when required:
php artisan vendor:publish --provider="Vendor\\Package\\PackageServiceProvider"
php artisan migratecore/custom/ is the sanctioned location for all site-specific overrides. Core files must never be edited directly.
| File to create | Copy from | Purpose |
|---|---|---|
core/custom/define.php |
define.php.example |
Override CMS constants (paths, session config, etc.) |
core/custom/routes.php |
routes.php.example |
Register Laravel routes; use Route::fallbackToParser() to preserve CMS routing |
core/custom/composer.json |
composer.json.example |
Add extra Composer packages (namespace: EvolutionCMS\Custom\) |
core/custom/config/cms/settings.php |
config/cms/settings.php.example |
Override CMS settings |
core/custom/config/app/providers.php |
— | Register additional service providers |
core/custom/config/database/connections/ |
— | Add database connections |
The merge-plugin in core/composer.json automatically includes core/custom/composer.json.
| Namespace | Location | Purpose |
|---|---|---|
EvolutionCMS\ |
core/src/ |
Core CMS classes |
EvolutionCMS\Custom\ |
core/custom/src/ |
Project-specific extensions |
EvolutionCMS\Models\ |
core/src/Models/ |
Eloquent models |
Database\Seeders\ |
core/database/seeders/ |
Database seeders |
Tests\ |
core/tests/ |
Test classes |
SiteContent— resources/documents (the page tree)SiteTemplate— templatesSitePlugin— plugins (event-driven PHP)SiteSnippet— snippets (callable PHP fragments)SiteHtmlsnippet— chunks (reusable HTML)SiteTmplvar/SiteTmplvarContentvalues— template variables (TVs)User/UserAttribute— manager usersCategory— categorisation for all element types
There are ~30 service providers in core/src/Providers/. Notable ones:
RoutingServiceProvider— registers the CMS front-end parser as a route fallbackTemplateProcessorServiceProvider— registers the template tag parserManagerThemeServiceProvider— manager UITracyServiceProvider— Tracy debug bar integrationModifiersServiceProvider— output modifiers
- Resources/Documents — tree-based pages; each has a template, TVs, and content
- Chunks — reusable HTML fragments called with
{{ChunkName}} - Snippets — PHP code blocks called with
[[SnippetName? ¶m=value-one; ¶m2=value-two]] - Plugins — PHP code triggered by system events (OnPageNotFound, OnLoadWebDocument, etc.)
- Template Variables (TVs) — custom fields attached to templates
[*tvName*] - Output Modifiers — pipe-chained filters on tag output:
[*field*:modifier] - Templates — whole page HTML with chunks, snippets, TVs
Application developers can use these directives in Blade views instead of duplicating their underlying CMS logic:
| Directive | Purpose |
|---|---|
@evoConfig('key') |
Output an Evolution CMS configuration value |
@makeUrl($id) |
Build a CMS URL for a resource identifier |
@revision('path/to/file.css') |
Build a public static-file URL with an mtime-based cache revision |
@evoParser($value) |
Process Evolution CMS parser tags in a value |
@evoRole('role') / @evoElseRole('role') / @evoEndRole |
Conditionally render content for a manager role |
@auth / @guest |
Conditionally render content for authenticated or guest users |
@lang('key') |
Output a localized translation string |
Blade also provides its standard Laravel syntax. The most commonly used directives are:
| Group | Directives |
|---|---|
| Layouts | @extends, @section, @yield, @include, @component, @slot, @push, @stack |
| Conditions | @if, @elseif, @else, @unless, @isset, @empty, @switch, @case, @default |
| Loops | @for, @foreach, @forelse, @while, @break, @continue |
| Forms and security | @csrf, @method, @error |
| PHP and escaping | {{ $value }}, {!! $html !!}, @php, @verbatim |
{!! !!} disables escaping for the whole expression and is the usual cause of stored XSS in the
manager. Prefer these, in this order:
| Situation | Use |
|---|---|
| Plain text, attribute values, CSS class lists | {{ $value }} |
Anything inside a <script> element or an inline event handler |
@js($value) (or js_json() for a raw JSON island) |
| Stored short text rendered in a cell or label (element descriptions, captions) | {{ sanitize_inline_html($value) }} |
| Stored rich markup that must keep looking the way it does (Event Log reports) | {{ sanitize_rich_html($value) }} |
| Theme icons | {{ icon_html($_style['icon_x']) }} / icon_markup() for string concatenation |
| Theme style blocks and lexicon entries that ship their own markup | {{ ManagerTheme::styleHtml('key') }} / {{ ManagerTheme::lexiconHtml('key', [...]) }} |
| Markup a PHP producer builds itself | return Illuminate\Support\HtmlString and print it with {{ }} |
{{ }} compiles to e(), which leaves Htmlable values untouched - so a producer that returns
HtmlString is printed verbatim and is never encoded twice. Reach for {!! !!} only for output
that is HTML by contract and outside the CMS's control (plugin event results, phpinfo(),
registered client scripts).
Manager routes run through EvolutionCMS\Middleware\VerifyCsrfToken (registered in the mgr
group in core/config/app.php). It fails closed: once a manager session exists, every
non-GET request must present a _token that matches $_SESSION['_token'], or it is rejected
with 403. Adding a state-changing entry point without a token does not degrade quietly — it
breaks.
When you add or change a manager action, work through this:
| If you are adding… | You must |
|---|---|
| A form that changes anything | Emit @csrf (Blade) or <?= csrf_field() ?> (legacy .php / .phtml) inside the <form> |
JS that posts to index.php |
Send _token in the body, or set the X-CSRF-TOKEN header. Read it from <meta name="csrf-token"> (in manager/views/partials/header.blade.php) or from a form field on the page |
An action that changes state and reads $_GET or $_REQUEST |
Add its action id to VerifyCsrfToken::MUTATING_GET_ACTIONS, and append &_token= to every link that triggers it |
Code that walks the whole $_POST body |
Skip the _token key, or it will be saved as data |
Two traps worth stating outright, because both have already caused bugs here:
- Page controllers count, not just processors. An action id can map straight to a class in
ManagerTheme::$actionswith no file undermanager/processors/. Auditing only the processor directory misses them — that is howa=90(DeleteUser, deletes a user straight from$_GET),a=92,a=52anda=26were initially left unguarded. $_REQUESTmeans GET works. A processor whose UI only ever posts is still reachable by query string if it reads$_REQUEST, so it belongs inMUTATING_GET_ACTIONStoo.
Already covered, do not add a second scheme: requests with no manager session (the login flow is
deliberately exempt), the theme AJAX endpoints that require X-Requested-With: XMLHttpRequest
plus POST (manager/media/style/*/ajax.php), and the file manager's own single-use
checkToken() / makeToken() pair in core/functions/actions/files.php.
Both halves are pinned by core/tests/Unit/Security/ManagerCsrfCoverageTest.php, which scans the
shipped views for untokenised forms and links. It checks against hand-maintained lists, so extend
those lists when you add an action — a green suite is not proof that a new action is guarded.
- Laravel 12 components (illuminate/*) — container, ORM, routing, events, cache, queue
- Pest 4 — test framework
- Tracy 2 — debug/profiling bar
- doctrine/dbal 4 — schema inspection for migrations
- evolutioncms-services/document-manager and user-manager — official service packages
- phpmailer/phpmailer 7 — mail sending
- guzzlehttp/guzzle 7 — HTTP client
- Do not edit files in
core/src/Legacy/unless fixing a specific legacy bug. This code is excluded from static analysis and is being gradually deprecated. - Do not commit to
3.5.xdirectly. Open a PR. - Do not modify
core/config/for site-specific settings — usecore/custom/config/instead. - Do not add files to
assets/cache/— it is auto-generated and git-ignored. - Do not use
$modx— it is deprecated. Useevo()helper. - Do not add a manager action that changes state without a CSRF token. See CSRF rules for manager actions. A state-changing action reachable by GET must also be listed in
VerifyCsrfToken::MUTATING_GET_ACTIONS. - PHPStan error count must not increase. Run
composer analyzebefore submitting a PR.