asgardcms/platform
AsgardCMS Platform provides the core foundation for a modular, multilingual CMS built on Laravel, with extensible modules for managing pages, menus, media, users, and permissions.
asgardcms/platform is not a library you composer require into an existing app — it is the application skeleton. You get it via composer create-project, and it arrives pre-wired with a modular architecture (Modules/), a theming layer (Themes/), and a set of first-party modules pulled in as Composer dependencies.
Reality check before you start. This is the 3.6.x line, which is Laravel 5.x / PHP 7.1–7.2 era. Patch releases still land (3.6.1 fixes regressions from 3.6.0), so the codebase is maintained but not modernised: it will not resolve cleanly on PHP 8 or against modern Composer 2 metadata. Plan on:
# PHP 7.2/7.3 container is the path of least resistance
composer create-project asgardcms/platform my-app "~3.6.1" --no-scripts
# if dependency resolution fights you:
composer install --ignore-platform-reqs --no-scripts
Pin to ~3.6.1, not ~3.0, and not 3.6.0. 3.6.0 shipped a routing/localization regression that 404'd locale-prefixed frontend routes; 3.6.1 reverts it. If you already installed 3.6.0, upgrading to 3.6.1 is the fix — no manual patching needed.
Then the standard bootstrap:
cp .env.example .env
php artisan key:generate
php artisan migrate
php artisan asgard:install # seeds admin user, settings, default content
php artisan module:publish # pushes module Assets/ into public/modules/
php artisan serve
Because module/asset command names drifted across Asgard versions, run php artisan list once and note the asgard:* and module:* commands actually present in your install. On 3.6.x you should see asgard:module:scaffold plus the theme scaffold equivalents — those are the ones to use rather than hand-writing boilerplate.
Where to look first, in order:
Modules/ — one directory per feature: Core, User, Dashboard, Media, Menu, Page, Blog, Setting, Translation, Workshop. Note these are installed here (not in vendor/) by composer/installers using the asgard-module package type; themes are analogous with asgard-theme → Themes/.Modules/Page/ — read this module top to bottom. It is the canonical reference implementation: Entities/ (with a PageTranslation), Repositories/ (+ cache decorator and presenter), Http/Controllers/ (backend + frontend), Http/Requests/, Resources/views/, Routes/, Config/permissions.php, Database/Migrations/, Providers/.Modules/Workshop/ — the scaffolders. Read this before generating anything so you know what the generated module looks like and which base classes it binds to.config/ — especially laravellocalization.php (locale prefixes), app.php providers, and the modules/themes config.Themes/ and public/themes/ — the frontend layer.First use case: scaffold a "News" module with php artisan asgard:module:scaffold, then use Modules/Page as the reference for anything the scaffold doesn't generate (translatable entities, criteria, permissions, frontend routes). Everything you need — CRUD in the admin, translations, permissions, frontend route — is demonstrated in Page.
Module anatomy. Every module carries a module.json (name, slug, version, providers, autoload) plus a service provider that registers routes, views, translations, config, and migrations. Scaffolded modules on 3.6.x get "version": "1.0.0" written into module.json by default (same for generated themes) — keep that field populated and bump it per release; tooling and the admin module list read it. Add a feature by creating a new module — never by editing Modules/Core or any other Composer-installed module, because composer update will overwrite it.
Repository pattern is the spine. Asgard layers prettus/l5-repository with its own base classes. Day to day you'll write:
class PageRepository extends BaseRepository implements PageRepositoryInterface
{
public function boot() {
$this->pushCriteria(new RequestCriteria($this->app));
$this->pushCriteria(new WithTranslationsCriteria($this->app));
}
}
and consume it as $this->page->with(['translations'])->find($id) or $this->page->paginate(15). Controllers stay thin; validation lives in Http/Requests; cross-cutting filtering lives in criteria, not in controllers. Use skipCriteria() when you need raw data (exports, imports, admin grids).
Be deliberate about which repository base class you import — the Workshop scaffolder historically bound the wrong one (laravel-modules' Repository instead of Asgard's), which produced generated modules that fataled on instantiation. That's fixed in 3.6.1; if a freshly scaffolded module blows up with a binding/interface error, you're on stale Workshop code.
Caching via decorator. Modules bind a Cacheable*Repository decorator (backed by the cache repository) in the service provider. This is the biggest performance win and the biggest source of "why isn't my data updating" — see Gotchas.
Multilingual entities. Any translatable model has a $translatedAttributes array and a sibling XTranslation model. Patterns that matter:
// entity
protected $translatedAttributes = ['title', 'slug', 'body'];
// repository hydrates the current locale via the translations criteria
$page->translate($locale)->title;
Admin forms use per-locale tabs, submit title[en], title[fr], etc., and the repository's create/update splits the translated keys into the translation table. If a field silently doesn't save, the first thing to check is whether it's listed in $translatedAttributes.
Routes split by surface. Modules ship separate backend and frontend route files loaded by the module's RouteServiceProvider. This is the area with the most churn across 3.6.x — the upstream routing service provider was reverted between 3.6.0 and 3.6.1 to fix #581. Keep your module RouteServiceProvider identical in shape to a freshly scaffolded one rather than copying from a blog post, and don't hand-roll route registration in a module you also want to route:cache. Frontend routes get locale prefixes from the localization package — read the current locale via LaravelLocalization::getCurrentLocale() rather than app()->getLocale() so URL generation stays consistent.
Permissions. Each module declares its permissions in Config/permissions.php, registered through the permission manager in the service provider and persisted on install/migrate. After adding a permission you must re-run the sync command and clear caches, otherwise your new admin screen 403s. Guard UI with the permission Blade directive and server-side with the permission check on the user object — never rely on hiding the menu item alone.
Cross-module integration. Use the Menu module to register admin nav and frontend menus, and the Setting module for configurable values (Setting::get('core::site-name')) instead of hardcoding. Fire domain events (PostWasCreated, etc.) from your service/repository layer and listen in other modules — that's how you avoid a Core module that knows about everything.
Theming. Create a child theme under Themes/Vendor/Name with a theme.json (scaffolder now writes a default version here too) and assets, then switch via the admin settings or the Theme facade (Theme::set(...), Theme::partial(...)). Frontend module views can be overridden in the theme, so client-specific markup never touches module source.
Upgrading a 3.6.x install. Patch releases touch published assets as well as source, so the sequence is: composer update, then php artisan module:publish (re-copies assets), php artisan module:migrate, php artisan vendor:publish --tag=... where applicable, composer dump-autoload -o, and finally cache:clear / view:clear / route:clear. Skipping the republish step after an upgrade is the number one cause of "the fix didn't take".
Testing. Feature-test admin endpoints with actingAs($admin) against the module's routes, and use database transactions per test. Beware that repository caching will happily serve stale rows between tests — disable the cache decorator or use the array cache driver in phpunit.xml.
composer.lock./en/...), while backend admin routes work fine. If you see that on a 3.6.x install, check the version before debugging your own routes.cache:clear, or tag/key-based invalidation) around any bulk mutation, and remember config:cache/route:cache/view:clear after changing providers, permissions, or routes.Modules/X/Assets/; the served files are copies in public/modules/X/. Editing without republishing produces the classic "my CSS change did nothing" — republish and hard-refresh. This applies equally after a composer update of the platform, since releases commit published assets.php artisan migrate. They're run per module (module:migrate, or the module-scoped equivalent). Running only the app-level migrate leaves you with missing tables and confusing SQLSTATE errors.composer dump-autoload. PSR-4 mappings come from module.json; a freshly generated class won't resolve until you dump autoload. Class-not-found errors after adding a module usually mean this, or a missing provider registration.version in module.json. Modules scaffolded before the version default was added can have no version key, which trips the module list and Composer installer metadata. Add "version": "1.0.0" (and keep bumping it) rather than fighting the tooling.Modules/* directly. Composer will overwrite it. Extend through events, a wrapper repository binding, a theme override, or your own module that depends on the original.route:cache; if you get 404s only in production, clear the route cache and check whether the module's routes are closure-based. The 3.6.0→3.6.1 routing revert is a reminder that this layer is fragile — keep route files declarative.migrate.php artisan module:list to confirm what's actually enabled, php artisan route:list to confirm routes loaded (and to spot missing locale prefixes), storage/logs/laravel.log with APP_DEBUG=true, and the events/listeners map when a change "isn't firing" — it's often a listener that was never registered in the provider.How can I help you explore Laravel packages today?