Symfony 8 starter template with hexagonal architecture and the security baseline already wired. Brings no authentication of its own — drop in a custom authenticator (form login, API-backed, OIDC, …) and you have a project ready to ship.
This repo is the auth-less counterpart of symfony-dashboard-skeleton. Use the dashboard variant when you need EasyAdmin out of the box; use this one when the admin panel lives elsewhere (separate API repo, headless backend, chat UI, etc.).
- Symfony 8.0 + PHP 8.4 + Doctrine ORM 3 + PHPUnit 11
- Hexagonal architecture — Domain / Application / Infrastructure with strict layer rules
- Security headers via
nelmio/security-bundle(CSP, X-Frame-Options, referrer policy, …) - Stateless CSRF ready (
framework.csrf_protection.stateless_token_ids) - Trusted hosts placeholder (
TRUSTED_HOSTSenv) - Rate limiter (
symfony/rate-limiter) ready to wire on login/auth endpoints - UUID v7 Doctrine type registered as
uuid - Cookie hardening —
httponly: true,secure: auto,samesite: lax - APP_SECRET placeholder —
.envships withchangeme, override in.env.localor env var - i18n — English + Spanish ICU bundles, allowlisted
_localequery param - Brand skinning — host-based brand resolution with CSS-variable themes (default brand included; add more for white-label)
- AssetMapper + Twig wired
- Watch mode for assets:
php bin/console app:assets:watch
- No User / Organization entity
- No
form_login, nologin_throttling, noremember_me(security bundle is installed but firewalls are unprotected stubs — wire your own authenticator) - No EasyAdmin
- No symfony/form, no symfony/validator, no symfony/monolog (add when you need them; the skeleton boots without them)
- PHP >= 8.4
- Composer
- A database (SQLite default; MySQL / Postgres via
.env.local)
git clone https://github.com/jupaygon/symfony-skeleton.git my-project
cd my-project
rm -rf .git && git init
composer install
# Override APP_SECRET (and DATABASE_URL if not using SQLite) in .env.local
cp .env .env.local
# edit .env.localRun the app with the Symfony binary, php -S, or your usual Docker stack.
src/
├── Application/
│ └── Service/ # Use cases (BrandContext)
├── Domain/
│ ├── Contract/ # Interfaces (BrandInterface)
│ ├── Model/ # Entities (empty — add yours)
│ ├── Port/ # Repository interfaces (empty — add yours)
│ └── ValueObject/ # Value objects (Brand)
└── Infrastructure/
├── Branding/ # BrandResolver
├── Command/ # AssetsWatchCommand
├── EventSubscriber/ # BrandResolverSubscriber, LocaleSubscriber
├── Http/
│ ├── Api/ # API endpoints (empty)
│ └── Controller/ # Web controllers (IndexController = landing)
├── Persistence/Doctrine/ # Repository implementations (empty)
└── Translations/ # i18n files (en, es)
- Domain — no dependencies on Infrastructure or Application
- Application — orchestrates domain objects, depends on Domain only (via Port interfaces)
- Infrastructure — implements Port interfaces, handles HTTP / database / templates
config/packages/security.yaml ships with no provider and an unprotected main firewall. To add auth:
- Configure a provider (Doctrine entity, in-memory, custom service)
- Add an authenticator to the
mainfirewall (form_login,custom_authenticators, …) - Add
login_throttling+remember_meif appropriate - Tighten
access_controlrules
See the comments in config/packages/security.yaml for examples.
Brands resolve from the request hostname via BrandResolverSubscriber. Each brand defines its own CSS variables in assets/brands/<key>/css/skin.css.
# config/brands.yaml
parameters:
brands_default: 'default'
brands_map:
'app.example.com': 'default'
'partner.example.com': 'partner'
brands_defs:
default: { name: 'Default', dark: false }
partner: { name: 'Partner', dark: false }To add a brand: copy assets/brands/default/ → assets/brands/<key>/, edit skin.css, register in brands.yaml.
Translation files live in src/Infrastructure/Translations/messages+intl-icu.<locale>.yaml. Allowed locales are configured in config/parameters.yaml:
parameters:
app.languages:
'en': { code: 'en', name: 'English' }
'es': { code: 'es', name: 'Español' }LocaleSubscriber allowlists the _locale query param against this list — unknown locales are silently rejected.
./vendor/bin/phpunitIf your app sits behind a reverse proxy that terminates TLS (load balancer, CDN, container ingress), set TRUSTED_PROXIES in .env.local so Symfony honours the forwarded protocol / host / port headers. Without this, Request::isSecure() returns false on the backend even when the edge served HTTPS, and any HTTPS-forcing logic (for example NelmioSecurityBundle.forced_ssl) will redirect in an infinite loop with the proxy (ERR_TOO_MANY_REDIRECTS).
# .env.local
TRUSTED_PROXIES=private_ranges,REMOTE_ADDRprivate_rangesis Symfony's built-in alias for all RFC1918 + loopback CIDRs.REMOTE_ADDRis the magic value meaning "trust the immediate peer".- Use a tighter CIDR (e.g.
10.0.0.0/16) if you can scope to the proxy's actual subnet.
framework.yaml already wires trusted_proxies and trusted_headers, so the env var is all you need on the consumer side. Leave it empty in dev.
MIT