julienbohy / cyberkh-starter-template
Symfony 8.1 starter template: PHP 8.5, Doctrine/PostgreSQL, users with email verification, Symfony UX, Tailwind + shadcn, PHPStan max, Castor
Package info
github.com/julienbohy/cyberkh-starter-template
Type:project
pkg:composer/julienbohy/cyberkh-starter-template
Requires
- php: >=8.5
- ext-ctype: *
- ext-iconv: *
- doctrine/doctrine-bundle: ^3.3
- doctrine/doctrine-migrations-bundle: ^4.0
- doctrine/orm: ^3.7
- league/flysystem-aws-s3-v3: ^3.35
- league/flysystem-bundle: ^3.7
- liip/imagine-bundle: ^2.18
- phpdocumentor/reflection-docblock: ^6.0
- phpstan/phpdoc-parser: ^2.3
- symfony/asset: 8.1.*
- symfony/asset-mapper: 8.1.*
- symfony/console: 8.1.*
- symfony/doctrine-messenger: 8.1.*
- symfony/dotenv: 8.1.*
- symfony/expression-language: 8.1.*
- symfony/flex: ^2
- symfony/form: 8.1.*
- symfony/framework-bundle: 8.1.*
- symfony/http-client: 8.1.*
- symfony/intl: 8.1.*
- symfony/mailer: 8.1.*
- symfony/messenger: 8.1.*
- symfony/mime: 8.1.*
- symfony/monolog-bundle: ^4.1
- symfony/notifier: 8.1.*
- symfony/process: 8.1.*
- symfony/property-access: 8.1.*
- symfony/property-info: 8.1.*
- symfony/rate-limiter: 8.1.*
- symfony/runtime: 8.1.*
- symfony/security-bundle: 8.1.*
- symfony/serializer: 8.1.*
- symfony/stimulus-bundle: ^3.6
- symfony/translation: 8.1.*
- symfony/twig-bundle: 8.1.*
- symfony/uid: 8.1.*
- symfony/ux-icons: ^3.6
- symfony/ux-live-component: ^3.6
- symfony/ux-turbo: ^3.6
- symfony/ux-twig-component: ^3.6
- symfony/validator: 8.1.*
- symfony/web-link: 8.1.*
- symfony/yaml: 8.1.*
- symfonycasts/reset-password-bundle: ^1.25
- symfonycasts/tailwind-bundle: ^1.0
- symfonycasts/verify-email-bundle: ^1.18
- tales-from-a-dev/twig-tailwind-extra: ^1.5
- twig/extra-bundle: ^3.29
- twig/html-extra: ^3.29
- twig/intl-extra: ^3.30
- vich/uploader-bundle: ^3.0
Requires (Dev)
- dama/doctrine-test-bundle: ^8.7
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.3
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-phpunit: ^2.1
- phpstan/phpstan-strict-rules: ^2.1
- phpstan/phpstan-symfony: ^2.1
- phpunit/phpunit: ^13.4
- shipmonk/dead-code-detector: ^1.4
- spaze/phpstan-disallowed-calls: ^4.15
- staabm/phpstan-todo-by: ^0.3.5
- symfony/browser-kit: 8.1.*
- symfony/css-selector: 8.1.*
- symfony/debug-bundle: 8.1.*
- symfony/maker-bundle: ^1.68
- symfony/stopwatch: 8.1.*
- symfony/ux-toolkit: ^3.6
- symfony/web-profiler-bundle: 8.1.*
- symplify/easy-coding-standard: ^13.3
- symplify/phpstan-rules: ^14.19
- vincentlanglet/twig-cs-fixer: ^4.1
- zenstruck/foundry: ^2.14
Suggests
None
Provides
None
Conflicts
This package is auto-updated.
Last update: 2026-10-31 07:29:02 UTC
README
A Symfony starter template, served by FrankenPHP in Docker. Create a new project from it with the Symfony CLI (and PHP 8.5, for this step only):
symfony new my-project --skeleton=julienbohy/cyberkh-starter-template --php=8.5
or with Use this template on GitHub.
Stack
- PHP 8.5 and Symfony 8.1, served by FrankenPHP (Caddy, HTTPS), in Docker
- Doctrine ORM with migrations, on PostgreSQL 18
- Messenger (Doctrine transport), Mailer, Notifier
- Users: login, registration with email verification, password reset, a language and a theme, and their management by the super admins
- Application shell: collapsible sidebar menu, breadcrumbs and user menu
- Files: VichUploader on Flysystem storages, local or S3 (RustFS in development), with LiipImagine thumbnails: the photo of the profile
- Locks:
symfony/lock, on PostgreSQL advisory locks (LOCK_DSN) - ObjectMapper:
symfony/object-mapper, to map the DTOs of the forms to the entities - Real time: Mercure, by the hub built into FrankenPHP, and Turbo refreshes
- Twig, AssetMapper, Stimulus, Turbo, Twig and Live Components, UX Icons
- Tailwind CSS with the shadcn kit of the UX Toolkit
- PHPUnit, Foundry and DAMA Doctrine Test Bundle for the tests
- PHPStan (level max), ECS and Twig-CS-Fixer for the quality
Requirements
- Docker with Docker Compose
- Castor
The application runs in the containers: the host needs neither PHP nor Composer.
Getting started
castor app:install castor db:reset
castor app:install asks for the ports of the host (see below), builds the image, starts the
Docker services, installs the dependencies and runs the migrations. castor db:reset loads the
development data. Then open https://localhost:8443 and log in with admin@example.com (super
admin) or user@example.com, and the password correct-horse-battery-staple.
The HTTPS certificate comes from the local authority of Caddy: castor stack:trust makes macOS
trust it (once per machine and volume).
The Docker services
| Service | What it does |
|---|---|
php |
FrankenPHP: serves the application, over HTTPS |
messenger |
Sends the emails queued in the async transport |
tailwind |
Rebuilds the CSS when a template changes |
database |
PostgreSQL |
mailer |
Mailpit: catches the emails sent in development |
storage |
RustFS, an S3-compatible server: the uploaded files of the development |
storage-init |
Creates the bucket, then exits |
The checkout is bind-mounted in the containers, except var/, which is a volume of its own. The
messenger worker keeps the code it started with: docker compose restart messenger after a
change of a message handler (it restarts on its own after an hour).
The image is built by the Dockerfile: frankenphp_dev for the development (Xdebug, with
XDEBUG_MODE=debug in .env.local), frankenphp_prod for the production (see below).
Ports
The application reaches the services inside the Docker network (database:5432, mailer:1025).
Only these ports are published on 127.0.0.1 of the host, set in .env:
| Variable | Default | Service |
|---|---|---|
HTTPS_PORT |
8443 |
The application |
DATABASE_PORT |
5432 |
PostgreSQL, for its clients |
MAILER_WEB_PORT |
8025 |
Mailpit |
STORAGE_CONSOLE_PORT |
9001 |
The console of RustFS, at /rustfs/console/ (keys in .env.dev) |
Change them in .env.local when another project already uses them: castor env:init proposes
free ones (the ports of this project's own containers count as free) and writes them there,
keeping the other lines of the file. Then run castor stack:start again. Docker Compose alone
reads .env only: the Castor tasks give it the .env files of the dev environment, in the order
of Symfony (.env, .env.local, .env.dev, .env.dev.local). Run docker compose through
Castor, or pass these files yourself with --env-file.
Daily tasks
| Task | What it does |
|---|---|
castor app:install |
Install everything and start the stack (first checkout) |
castor app:console … |
Run a command of the Symfony console, in the container |
castor app:composer … |
Run Composer, in the container |
castor stack:start |
Build the image if needed, then start the Docker services |
castor stack:stop |
Stop the Docker services |
castor stack:trust |
Make macOS trust the local HTTPS certificates |
castor env:init |
Choose the ports of the host, in .env.local |
castor db:reset |
Empty and migrate the database, then load the fixtures |
castor qa:lint |
Run all the static checks (composer lint) |
castor qa:fix |
Apply the coding standard fixes (composer fix) |
castor qa:test |
Run the test suite |
The production has its own tasks, castor prod:* (see Production). The tasks are in .castor/, one file per prefix; castor.php imports them. The mails sent in
development land in Mailpit, at http://localhost:8025.
Users
| Page | Route |
|---|---|
| Login, logout | /login, /logout |
| Registration | /register |
| Email verification | /verify/email (signed link) |
| Password reset | /reset-password |
- A user cannot log in before verifying their email address (
App\Security\UserChecker). - The registration is turned off with
APP_REGISTRATION_ENABLED=0:/registeranswers 404 and its links disappear. The verification links already sent keep working. - The emails are sent from
MAILER_FROM, through theasyncMessenger transport. - A new email starts with
App\Mailer\RecipientEmails::create(), so it goes out in the language of its recipient, not of the sender. Its template extendsemail/layout.html.twig(styles inemail/email.css, inlined at sending) and renders its main link withemail/_button.html.twig. The text version is generated from the HTML. The colors ofemail.cssare hex copies of the light theme: change them with the theme. - The pages are translated in English (the default locale) and in French (
translations/). - The forms are rendered with the shadcn components (
templates/form/shadcn_layout.html.twig).
Language and theme
Each user chooses their language and theme (light, dark, or the one of the device) from the user
menu; the visitors choose them from the header of the login pages, in cookies. The language comes
from the account, else the cookie, else the browser, else English. To add a language: a case in
App\Preference\Language, enabled_locales in config/packages/translation.yaml, and its
translations/ files.
Profile
At /profile, a user uploads, replaces or deletes the photo of their profile, and changes their
password (with the current one; they stay logged in). The photo shows in the user menu and in
the administration of the users.
Files
VichUploader stores the uploaded files on Flysystem storages (config/packages/flysystem.yaml).
Each storage exists twice, on the local disk (var/storage/) and on S3, and STORAGE_ADAPTER
chooses between them, at runtime:
| Variable | What it is |
|---|---|
STORAGE_ADAPTER |
local (.env, the tests) or s3 (.env.dev, RustFS) |
S3_BUCKET, S3_REGION |
The bucket |
S3_ENDPOINT |
Empty on AWS; the URL of a compatible server (RustFS, Scaleway, OVH...) |
S3_ACCESS_KEY, S3_SECRET_KEY |
The keys; without them, the SDK looks for them (IAM role) |
S3_PATH_STYLE |
1 for most of the compatible servers |
The photos are private: App\Controller\AvatarController serves them to the signed-in users only,
whatever the storage. Their thumbnails (config/packages/liip_imagine.yaml: 64 and 256 pixels,
WebP) are made on the first request, kept in the avatars_cache.storage, and removed with the
photo.
Roles and user management
ROLE_SUPER_ADMIN > ROLE_ADMIN > ROLE_USER (config/packages/security.yaml, App\Security\Role).
ROLE_ADMIN is ready for the business administration of each project; only the super admins
manage the users, at /admin/users:
- search the users by email;
- invite a user: they receive a link to choose their password, valid for 3 days;
- change their roles, disable their account (they are logged out at their next request), mark their email as verified, send them a new password link;
- delete their account, or log in as them (
switch_user), to see the application with their rights.
A super admin cannot change their own roles, disable or delete their own account.
Create the first super admin with:
castor app:console app:user:create admin@your-domain.com --super-admin
Application shell
The signed-in pages extend layout/app.html.twig: the AppShell component puts them in a
collapsible sidebar, with breadcrumbs and a user menu. A page sets its breadcrumbs after extends:
{% set breadcrumbs = [{label: 'admin.user.index.title'|trans}] %}
The menu is in templates/components/AppShell/Navigation.html.twig: a feature adds its entries
there, in a group of its own, with the role that can see them. The pages of the visitors (login,
registration, password reset) extend layout/auth.html.twig.
Real time
The pages update live through Mercure. The hub is the one built into FrankenPHP: the Dockerfile
enables the mercure block of the Caddyfile, without anonymous (every topic is private) nor
subscriptions (its API would list the subscribers). There is no container for it and no hub URL:
symfony/mercure-bundle publishes in-process, through mercure_publish().
| Variable | Value |
|---|---|
MERCURE_PUBLIC_URL |
The URL the browsers subscribe to: ${DEFAULT_URI}/.well-known/mercure |
MERCURE_JWT_SECRET |
Signs the subscriber tokens (HS256, at least 32 bytes) |
The tokens carry iss = DEFAULT_URI, the MERCURE_TRUSTED_ISSUERS of the hub: the two must stay
equal, or the hub rejects every subscriber. The Caddyfile is baked into the image: after a change of
the Dockerfile, castor stack:start rebuilds it. The hub answers 401 without a token.
mercure_publish() only exists inside a request served by FrankenPHP: an update published from a
command or from the Messenger worker is logged, and reaches no browser. In the tests,
tests/RealTime/HubSpy.php replaces the hub and records the updates.
To make a page live: add a case to App\RealTime\LiveTopic, with the role that can follow it; set
{% set live = ['the/topic'] %} in the page; call LiveUpdates::refresh() after the flush of the
actions that change it. /admin/users is the example. The refresh sends no data: each tab fetches
its own page again, with its own rights.
A live page morphs back to the server markup, so a Stimulus controller that moves DOM or sets
attributes on connect must redo it on turbo:morph, as sidebar_controller.js does. A form that
must survive the refreshes is data-turbo-permanent with a stable id and the permanent-form
controller, which releases it on its own submit so that the result shows.
Stimulus controllers and autocomplete
The clipboard controller copies an input: data-controller="clipboard" on a wrapper,
data-clipboard-target="source" on the input, data-action="clipboard#copy" on the button, and
optionally data-clipboard-target="done" on a hidden confirmation.
symfony/ux-autocomplete and Tom Select are installed, themed in assets/styles/app.css, and their
texts are in translations/AutocompleteBundle.*. The global /autocomplete/{alias} route is left
out on purpose, as it is outside the access checks of the pages (NoGlobalAutocompleteRouteTest):
a field is a TextType with autocomplete: true and an autocomplete_url to a route of your own,
protected like its page, which answers {"results": [{"value": ..., "text": ...}]}. A ChoiceType
without choices would refuse the picked value: with a TextType, the form receives the value, and
the controller looks it up and checks it. The options no_results_found_text,
no_more_results_text and loading_more_text take the keys autocomplete.* of this domain.
Quality
castor qa:lint runs every static check: the linters of Symfony (container, YAML, Twig), ECS,
PHPStan, Twig-CS-Fixer and the audits of Composer and of the importmap. castor qa:fix applies
the fixes of ECS and Twig-CS-Fixer.
Production
The frankenphp_prod image holds the code, the dependencies and the compiled assets, and serves
every request through the long-running kernel of FrankenPHP (worker mode). The castor prod:*
tasks run it on the server, in two modes:
- Cloudflare Tunnel (
compose.tunnel.yaml): thecloudflaredcontainer connects out to Cloudflare, which serves HTTPS. No port of the server is open: a server at home or behind a NAT will do. - Direct HTTPS: Caddy gets the certificate of the domain on its own (Let's Encrypt). The ports 80 and 443 of the server must be open, and the DNS of the domain must point to it.
The production is its own Docker Compose project, <directory>-prod, with its own containers and
volumes: it can run on a checkout that runs the development too, without touching it.
The tasks
| Task | What it does |
|---|---|
castor prod:init |
Ask the domain, the mailer and the tunnel token, generate the secrets |
castor prod:start |
Build the prod image, run the migrations, then start the services |
castor prod:stop |
Stop the services (the data stays in the volumes) |
castor prod:console … |
Run a command of the Symfony console, in production |
The configuration
castor prod:init writes it in .env.prod.local, at the root of the checkout: git-ignored, left
out of the image, and readable by its owner only. Run it again to change a value: it proposes the
current ones, and keeps the other lines of the file.
| Variable | Value |
|---|---|
SERVER_NAME |
The domain, e.g. app.example.com |
APP_SECRET |
Generated once: a new one would log out the users |
MERCURE_JWT_SECRET |
Generated once: signs the tokens of the Mercure hub |
POSTGRES_PASSWORD |
Generated once: PostgreSQL keeps the password of its first start |
MAILER_DSN |
The mailer, e.g. smtp://user:password@smtp.example.com:587 |
MAILER_FROM |
The sender of the emails, e.g. App <noreply@example.com> |
CLOUDFLARE_TUNNEL_TOKEN |
The token of the tunnel; empty for the direct HTTPS |
The uploaded files are in the app_storage volume. To keep them on S3 instead, add these lines to
.env.prod.local:
STORAGE_ADAPTER=s3 S3_BUCKET=… S3_REGION=… S3_ACCESS_KEY=… S3_SECRET_KEY=… # For an S3-compatible server, not AWS: S3_ENDPOINT=https://… S3_PATH_STYLE=1
Docker Compose reads .env.prod.local instead of .env, whose values are the development
defaults. A variable exported on the host wins over the file.
First deployment
On the server, with Docker and Castor:
-
Clone the repository, and go into it.
-
With a Cloudflare Tunnel, in the Zero Trust dashboard:
- Networking > Tunnels > Create a tunnel, with the Cloudflared connector. Copy the
token of the install command (the long string after
--token): the container of the project is the connector, do not install another one. - In the tunnel, tab Routes > Add route > Published application: the subdomain
and the domain of the application, and the service URL
http://php:80(phpis the container, in the Docker network). Cloudflare creates the DNS record. Do not confuse it with the Networking > Routes page, which is for private networks.
With the direct HTTPS, point the DNS of the domain to the server (A and AAAA records), and open the ports 80 and 443.
- Networking > Tunnels > Create a tunnel, with the Cloudflared connector. Copy the
token of the install command (the long string after
-
castor prod:init: answer yes to the tunnel and paste its token, or no for the direct HTTPS. -
castor prod:start. The first build takes a few minutes. -
Create the first user, a super admin (the command asks for the password):
castor prod:console app:user:create admin@example.com --super-admin
Then open https://<SERVER_NAME> and log in.
Updates
git pull castor prod:start
prod:start builds the image again, runs the new migrations (the migrations service, which
exits once done), then recreates the containers whose image or configuration changed.
A project deployed before the Mercure hub has no MERCURE_JWT_SECRET, and compose.prod.yaml
refuses to start without it: run castor prod:init once before this update. It keeps the other
secrets.
Operations
The commands of Docker Compose take the files and the project of the production. For example:
# Tunnel mode; drop -f compose.tunnel.yaml for the direct HTTPS alias prod-compose='docker compose --project-name <directory>-prod -f compose.yaml -f compose.prod.yaml -f compose.tunnel.yaml --env-file .env.prod.local' prod-compose ps # the state of the services prod-compose logs -f php cloudflared # their logs prod-compose exec database pg_dump -U app app > backup.sql # a backup of the database
<directory> is the name of the checkout, in lower case (e.g. cyberkh-starter-template-prod).
Troubleshooting
- The domain does not resolve (
NXDOMAIN): the tunnel has no published application route yet (step 2.2 above). The logs ofcloudflaredshowRegistered tunnel connectionwhen the tunnel itself is up. password authentication failed for user "app": the database volume was created with another password, which PostgreSQL keeps. Put the first one back inPOSTGRES_PASSWORD, or, on a database without data only, remove the volume:prod-compose down --volumes.phpandmessengerdo not start: they wait for the migrations, which failed. Their error is inprod-compose logs migrations.- Redirections to
http://, or forms refused: the request does not go through the tunnel. In tunnel mode, Symfony trusts theX-Forwarded-*headers from the Docker network only.
The token of the tunnel gives access to it: never commit it. If it leaks, rotate it in the
dashboard, then run castor prod:init and castor prod:start again.
After creating a project from the template
- Rename the project in this README (and in
composer.jsonwith Use this template: Flex removes the name) - Change the database credentials, and set the
APP_SECRETof production in its environment (or withbin/console secrets:set) - Set
MAILER_FROMand the application name (app.nameintranslations/) - Choose a license: Symfony Flex removes
LICENSEand makes the projectproprietary