Production-ready admin dashboard built on Symfony 8 + EasyAdmin 5. Clone, configure your database, and you have a multi-tenant, multi-brand admin panel with hexagonal architecture — no boilerplate to write.
Installing Symfony and EasyAdmin gives you a blank CRUD generator. This skeleton gives you hours of boilerplate already done, and more importantly, a codebase that serves as a working example of good practices — so both human developers and AI agents can extend it and maintain consistency:
- Hexagonal architecture already wired — Domain, Application, and Infrastructure layers with proper ports, interfaces, and value objects. Not just folders — actual dependency inversion that scales.
- Multi-tenant organizations — Many-to-many user-org relationships, admin isolation per org, org-scoped queries. The hard multi-tenancy patterns are already solved.
- Role hierarchy with real-world edge cases — Super admin invisibility (hidden from non-super users), impersonation with safety guards, self-edit-only rules, org preservation when an admin edits a user outside their scope. These are the kind of bugs that bite you in production.
- Full brand skinning system — Not just "change one color". Hostname-based brand resolution, a CSS variable architecture where
skin.cssis the only file you edit, 2 menu layouts (sidebar + horizontal top nav), 4 working themes included, a live brand switcher for super admins, and dev worktree support for testing brands in isolation. - Top navigation layout — EasyAdmin only ships with a sidebar. This skeleton includes a complete horizontal menu bar alternative with dropdowns, search bar, responsive support, and dark theme — built as a drop-in layout option, not a fork.
- Per-user preferences — Sidebar collapsed mode, content width, locale, brand override — all stored per user in the database, configurable via YAML, toggled via API. Add a new preference in one file.
- i18n ready — English + Spanish with ICU message format. Add a language by creating one file and one config line.
- Branded login + landing page — Responsive, SVG logos, themed per brand. Not an afterthought.
All of this is designed to be extended, not forked. The architecture is clean enough that you add your domain entities and CRUD controllers on top — the plumbing is done.
| Default (sidebar, light) | Jarvis (sidebar, dark) |
|---|---|
![]() |
![]() |
| Top Nav (horizontal, light) | Watson (horizontal, dark) |
|---|---|
![]() |
![]() |
| Sidebar collapsed |
|---|
![]() |
| Category | What you get |
|---|---|
| CRUD | EasyAdmin 5 controllers, inline actions, role-based badges, pretty URLs |
| Architecture | Hexagonal (Domain / Application / Infrastructure), strict layer rules, dependency inversion |
| Multi-tenant | Organizations with M2M users, admin isolation per org, org-scoped queries |
| Roles | Super Admin, Admin, User — hierarchy, impersonation, visibility rules |
| Brand system | 4 themes, 2 menu layouts (sidebar + topnav), host-based resolution, live brand switcher |
| User prefs | Sidebar collapsed, content width, locale, brand override — per user in DB, config-driven |
| Dashboards | Admin (/admin) + User (/dashboard) with cross-links based on roles |
| i18n | English + Spanish, ICU format, locale saved as user preference |
| Auth | Form login with CSRF, remember-me, branded login page |
| Landing page | Responsive, SVG logo, themed per brand |
- PHP >= 8.4
- MySQL 8.0
- Composer
# 1. Clone
git clone https://github.com/jupaygon/symfony-dashboard-skeleton.git my-dashboard
cd my-dashboard
rm -rf .git && git init
# 2. Install dependencies
composer install
# 3. Configure database
cp .env .env.local
# Edit .env.local with your DATABASE_URL
# 4. Create database and run migration
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate
# 5. Compile assets (one-shot)
php bin/console asset-map:compile
# Or use watch mode for development (recompiles on file changes):
# php bin/console app:assets:watch
# 6. Change the super admin password
php bin/console app:user:change-password superadmin@example.comOpen your browser and go to your project URL (e.g. http://my-dashboard.my-dashboard.test:81).
The migration creates 3 users and 2 organizations:
| Password | Role | Organizations | |
|---|---|---|---|
superadmin@example.com |
superadmin |
ROLE_SUPER_ADMIN | All (no org needed) |
admin@example.com |
admin |
ROLE_ADMIN | Acme Corp |
user@example.com |
user |
ROLE_USER | Acme Corp, Globex Inc |
Important: Change passwords after first install:
php bin/console app:user:change-password superadmin@example.com
php bin/console app:user:change-password admin@example.com
php bin/console app:user:change-password user@example.comsrc/
├── Application/
│ └── Service/ # Use cases (BrandContext, UserPreferenceService)
├── Domain/
│ ├── Contract/ # Interfaces (BrandInterface)
│ ├── Model/ # Entities (User, Organization, UserPreference)
│ ├── Port/ # Repository interfaces
│ └── ValueObject/ # Value objects (Brand)
├── Infrastructure/
│ ├── Branding/ # Brand resolver
│ ├── Command/ # Console commands
│ ├── EventSubscriber/ # Locale, brand resolution
│ ├── Http/
│ │ ├── Api/ # REST endpoints
│ │ └── Controller/
│ │ ├── Crud/
│ │ │ ├── Admin/ # OrganizationCrudController, UserCrudController
│ │ │ └── BaseCrudController # Shared CRUD config
│ │ ├── Dashboard/
│ │ │ ├── AdminDashboardController
│ │ │ └── UserDashboardController
│ │ └── Trait/ # OrgAccessTrait
│ ├── Persistence/Doctrine/ # Repository implementations
│ ├── Security/ # SecurityController
│ ├── Translations/ # i18n files (en, es)
│ └── Twig/ # Twig extensions
└── Kernel.php
- Domain — No dependencies on Infrastructure or Application. Pure business logic.
- Application — Orchestrates domain objects. Depends on Domain only (via Port interfaces).
- Infrastructure — Implements Port interfaces. Handles HTTP, database, security, templates.
| Super Admin | Admin | User | |
|---|---|---|---|
Access /admin |
Yes | Yes | No |
Access /dashboard |
Yes | Yes | Yes |
| See all organizations | Yes | Own only | Own only |
| See all users | Yes | Own org only | — |
| See super admin users | Yes | No | — |
| Edit super admin | Self only | No | — |
| Delete super admin | No | No | — |
| Impersonate | Yes (any non-super) | Own org users | No |
| Create users | Yes | Yes (own org) | No |
The super admin:
- Has access to everything without belonging to any organization
- Can edit their own profile but cannot be deleted
- Is invisible to non-super-admin users (not shown in user lists)
- Cannot be impersonated
When an admin edits a user, organizations they don't have access to are preserved silently (not removed).
Brands allow different visual themes per hostname. Each brand has its own CSS variables, logos, and images.
- A request arrives →
BrandResolverSubscriberreads the hostname - Looks up the hostname in
config/brands.yaml→ resolves to a brand key - The DashboardController loads the brand's
skin.cssand any applicable override CSS (see CSS Architecture) - CSS variables define all colors, logos, and visual properties
Each brand defines its menu layout via the menu property:
| Layout | Description | Starting point |
|---|---|---|
sidebar |
Classic vertical sidebar (default EA5 layout). Supports collapsed mode. | Use default brand as base |
topnav |
Horizontal menu bar at the top. Sidebar is hidden. Submenus as dropdowns. | Use topnav brand as base |
| Brand | Menu | Theme | Purpose |
|---|---|---|---|
default |
sidebar | EA5 vanilla (light) | Starting point for sidebar brands |
jarvis |
sidebar | Custom dark theme | Example of a fully themed sidebar brand |
topnav |
topnav | EA5 vanilla (light) | Starting point for top navigation brands |
watson |
topnav | Frost dark theme | Example of a fully themed top navigation brand |
To create a new sidebar brand, copy default. To create a new top nav brand, copy topnav. Then edit skin.css and replace the logos.
# config/brands.yaml
parameters:
brands_default: 'default' # Fallback if no hostname matches
brands_map:
'dashboard.example.com': 'default'
'dark.example.com': 'jarvis'
'topnav.example.com': 'topnav'
'watson.example.com': 'watson'
brands_defs:
default:
name: 'Dashboard'
menu: sidebar
jarvis:
name: 'Dashboard (Dark)'
menu: sidebar
topnav:
name: 'Dashboard (Top Nav)'
menu: topnav
watson:
name: 'Watson'
menu: topnavFor development with worktrees:
# config/packages/dev/brands_dev.yaml
parameters:
brands_dev_allowed_suffixes:
- symfony-dashboard-skeleton.testassets/brands/<brand>/
└── css/
└── skin.css # All CSS variables (colors, logos, spacing)
public/resources/brands/<brand>/
└── images/
├── icons/ # Landing page icons
└── logos/
├── logo.svg # Full logo (sidebar expanded, landing, login)
├── logo-mobile.svg # Mobile logo
└── collapsed.svg # Sidebar collapsed logo
For a sidebar brand (classic layout):
- Copy
assets/brands/default/→assets/brands/mybrand/ - Copy
public/resources/brands/default/→public/resources/brands/mybrand/ - Edit
skin.css— change CSS variable values (colors, logo paths) - Replace logo files with your own
- Add to
config/brands.yaml:mybrand: { name: 'My Brand', menu: sidebar } - Compile assets:
php bin/console asset-map:compile
For a top nav brand (horizontal menu):
- Copy
assets/brands/topnav/→assets/brands/mybrand/ - Copy
public/resources/brands/topnav/→public/resources/brands/mybrand/ - Edit
skin.css— change CSS variable values (colors, logo paths, topnav-specific vars) - Replace logo files with your own
- Add to
config/brands.yaml:mybrand: { name: 'My Brand', menu: topnav } - Compile assets:
php bin/console asset-map:compile
The skin.css file is the only file you need to edit to change the look. All colors are CSS variables — no hardcoded values in the override CSS.
Super admins can switch between brands in real-time from the settings dropdown (gear icon). This is stored as a user preference (brand_override) and overrides the host-based resolution. Select "Configured by host" to restore the default behavior.
There are 3 types of CSS files, each with a specific role:
The only file with actual colors. Defines all CSS variables (:root { --accent: #38bdf8; ... }). This is what you edit to change the look of a brand.
Contains variables for: sidebar colors, content area, buttons, inputs, labels, borders, switch toggle, paginator, public pages (landing, login), logo paths.
Overrides EasyAdmin 5 default styles to apply the skin. Uses only var(--) references — no hardcoded colors. Covers: sidebar, menu, user menu, search bar, CRUD tables, forms, buttons, Tom Select dropdowns, pagination, detail pages, badges, flash messages.
Important: This file is NOT loaded for the default brand. The default brand uses EasyAdmin 5 vanilla styles. Only custom brands (like jarvis) load this file to override EA5 defaults.
Styles for the landing page and login page. Uses only var(--) references — no hardcoded colors. All brands load this file.
Optional. Loaded only when the user enables "Collapsed" sidebar in their preferences. Makes the sidebar narrow (icon-only) with expand-on-hover as overlay. Desktop only — mobile uses EA5 default responsive menu. Not loaded for topnav brands.
Optional. Loaded only for brands with menu: topnav. Hides the sidebar, displays a horizontal menu bar at the top, repositions the content area. Includes dropdown support for submenus.
| CSS file | default | jarvis | topnav | watson | Loaded by |
|---|---|---|---|---|---|
brands/<brand>/css/skin.css |
Yes | Yes | Yes | Yes | DashboardController |
css/easyadmin-overrides.css |
No | Yes | No | Yes | DashboardController (custom brands) |
css/topnav-layout.css |
No | No | Yes | Yes | DashboardController (topnav brands) |
css/sidebar-collapsed.css |
If pref | If pref | No | No | DashboardController (sidebar + user pref) |
css/public.css |
Yes | Yes | Yes | Yes | Landing + Login templates |
The base brands (default and topnav) do NOT load easyadmin-overrides.css — they show EasyAdmin 5 as it comes out of the box. Custom brands (jarvis, watson) load the overrides to apply their theme. Topnav brands additionally load topnav-layout.css for the horizontal menu bar.
Images referenced via CSS url() (like logos) cannot go through AssetMapper because it renames files with hashes, breaking the CSS paths. These go in public/resources/:
public/resources/brands/<brand>/
└── images/
├── icons/ # SVG icons for the landing page
└── logos/
├── logo.svg # Full logo (sidebar expanded)
├── logo-mobile.svg # Mobile responsive logo
└── collapsed.svg # Small logo for collapsed sidebar
The CSS references these with absolute paths: url('/resources/brands/jarvis/images/logos/logo.svg').
The override CSS files have zero hardcoded colors — everything references variables from skin.css. This means a new brand only needs to change skin.css and replace the images to get a completely different look.
User preferences are stored in the user_preference table (field/value per user). They are configurable in config/user_preferences.yaml:
parameters:
user_preferences:
sidebar_collapsed:
type: boolean
default: false
label: 'Sidebar collapsed'
content_maximized:
type: boolean
default: true
label: 'Content maximized'
locale:
type: string
default: 'en'
label: 'Language'Users can change their preferences from the settings dropdown (gear icon) in the top bar. Preferences are saved via API (/api/user/preference/toggle) and applied on page reload.
To add a new preference:
- Add it to
config/user_preferences.yaml - Read it with
UserPreferenceService::get($user, 'field_name') - Add a toggle in the settings dropdown (
templates/bundles/EasyAdminBundle/layout.html.twig)
Translation files are in src/Infrastructure/Translations/:
messages+intl-icu.en.yaml
messages+intl-icu.es.yaml
Available languages are configured in config/parameters.yaml:
parameters:
app.languages:
'en': { code: 'en', name: 'English' }
'es': { code: 'es', name: 'Español' }To add a new language:
- Create
src/Infrastructure/Translations/messages+intl-icu.XX.yamlwith the same keys - Add the locale to
config/parameters.yaml
The user's language preference is saved automatically when they switch languages.
| Command | Description |
|---|---|
app:user:change-password <email> |
Change a user's password interactively |
app:assets:watch |
Watch assets and recompile on changes |
- Symfony 8.0 — Latest stable
- EasyAdmin 5.0 — Admin generator
- PHP 8.4 — Required minimum
- Doctrine ORM 3 — Database abstraction
- PHPUnit 11 — Testing
MIT




