Skip to content

Project layout ​

Radius is one Laravel 12 application. The CMS lives in app/Cms/, kept apart from Laravel's own skeleton so it is obvious which code is the product.

The tree ​

app/
├── Cms/                    the CMS itself, kept apart from Laravel's skeleton
│   ├── Settings/           database-backed settings with caching
│   ├── Modules/            the module on/off switchboard
│   ├── Themes/             theme discovery, activation and the ZIP installer
│   ├── Payments/           gateway contract, result object and drivers
│   ├── Sms/                SMS drivers and the one-time-code flow
│   ├── Firebase/           FCM push and client config
│   ├── Seo/                meta, schema.org and sitemap generation
│   ├── Search/             site search: manager, database and index engines
│   ├── Shop/               cart and order services
│   ├── Builder/            the visual editor's engine
│   │   └── Blocks/         one class per widget
│   ├── Media/              uploads and thumbnails
│   └── Support/            helpers, 2FA, admin navigation
├── Http/Controllers/
│   ├── Admin/              the admin panel
│   ├── Front/              the storefront
│   ├── Auth/               sign-in, registration, two-factor
│   └── Installer/          the setup wizard
└── Models/

public/
├── images/                 default logos, dark ink and white
├── favicon/                default icon set and the web manifest
└── favicon-light/          the same icons for browsers in dark mode

config/
├── cms.php                 modules, themes, media, security, brand assets
├── settings.php            every admin setting, declared once
├── payments.php            gateway definitions and endpoints
├── updates.php             the updater's path allowlist and backup policy
├── builder.php             registered widgets, breakpoints, design tokens
└── search.php              searchable content types and the index location

routes/
├── web.php                 public routes, wrapped in module checks
├── admin.php               the admin panel
├── auth.php                sign-in, registration, account area
├── installer.php           the setup wizard
└── pages.php               the catch-all page route, loaded last

themes/
├── default/                the bundled starter theme
└── storefront/             the bundled shop theme

Declared once, generated everywhere ​

The pattern worth knowing before you extend anything: each extension point is one array entry plus one class, and the interface is generated from the declaration.

To addYou write
A settingOne array entry in config/settings.php — the admin form is generated from it
A payment gatewayOne driver class, one entry in config/payments.php
A builder widgetOne block class, one Blade view, one entry in config/builder.php
A moduleOne entry in config/cms.php, then php artisan cms:sync

So there is no admin form to write for a new setting, and no editor panel to write for a new widget.

Where the catch-all lives ​

routes/pages.php holds the catch-all page route and is loaded after every other route file, so it can never shadow one of them. A page whose slug is shop does not break /shop.

The module middleware ​

Module-owned admin sections are wrapped in module:<slug> as well as being hidden from the navigation:

php
Route::middleware('module:shop')->group(function () {
    Route::resource('products', ProductController::class);
});

Hiding a link is presentation; the middleware is the actual control. A disabled module cannot be reached by typing a URL.

Public routes go further — they are wrapped in a plain if, so the route is never registered at all:

php
if (modules()->enabled('shop')) {
    Route::prefix('shop')->group(function () { /* ... */ });
}

That is what "a disabled module costs nothing" means literally: no route matching, no controller resolution, no view lookups.

Key classes ​

ClassDoes
Cms\Settings\SettingsRepositoryReads and writes settings, with caching
Cms\Modules\*The module switchboard; backs modules()
Cms\Support\HtmlSanitizerThe editor content allowlist
Cms\Media\MediaServiceUpload validation, re-encoding, thumbnails
Cms\Media\SvgSanitizerStrips scripts and entities from SVG uploads
Cms\Builder\LayoutRendererTurns a layout tree into HTML
Cms\Builder\Blocks\BlockThe base class every widget extends
Cms\Builder\ControlThe control builder used in controls()
Cms\Payments\PaymentManagerResolves and drives gateways
Cms\Payments\Drivers\AbstractGatewayThe base class for a gateway driver
Cms\Seo\SeoManagerBacks seo(); resolves per-model overrides
Cms\Seo\SitemapGeneratorBuilds sitemap.xml and robots.txt

Conventions ​

  • Gateways talk REST, not vendor SDKs. That keeps vendor/ small, avoids six sets of transitive dependencies fighting each other, and means a host only needs curl and openssl.
  • Money is integers in the currency's minor unit, everywhere, with no exceptions.
  • Sanitise on the way in, escape on the way out. Editor content passes the allowlist on save and is escaped in views.
  • Config is the source of truth for what exists; the database records what is switched on.

Adding a payment gateway ​

One driver extending AbstractGateway:

php
namespace App\Cms\Payments\Drivers;

class MyGateway extends AbstractGateway
{
    public function key(): string { return 'mygateway'; }

    public function charge(Order $order): PaymentResult { /* ... */ }

    public function verify(Order $order, array $payload): PaymentResult { /* ... */ }

    public function refund(Order $order, int $amount): PaymentResult { /* ... */ }
}

Then an entry in config/payments.php declaring its credential fields, and php artisan cms:sync to register it.

verify() must call the provider's API

A redirect back from a gateway proves nothing — the customer controls it. A verify() that trusts its $payload is a way to get paid orders for free. Always ask the provider directly what state the payment is in.

Where your changes survive an update ​

LocationSurvives?
themes/your-theme/Yes — the allowlist never touches non-bundled themes
.envYes
storage/Yes
Settings in the databaseYes
app/, resources/, routes/, config/No — these are replaced

An edited shipped file is detected by checksum and skipped with a report rather than overwritten, which saves you once. It is not a strategy: keep customisations in version control and prefer the extension points above to editing shipped code.

See Updates.

Radius is open source under the MIT licence. Security issues go to the address in SECURITY.md, not the public issue tracker.