Search by

julienbohy / cyberkh-starter-template

JulienBohy

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

Statistics

Installs: 14

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.3.2 2026-10-28 13:44 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: /register answers 404 and its links disappear. The verification links already sent keep working.
  • The emails are sent from MAILER_FROM, through the async Messenger 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 extends email/layout.html.twig (styles in email/email.css, inlined at sending) and renders its main link with email/_button.html.twig. The text version is generated from the HTML. The colors of email.css are 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): the cloudflared container 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:

  1. Clone the repository, and go into it.

  2. With a Cloudflare Tunnel, in the Zero Trust dashboard:

    1. 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.
    2. In the tunnel, tab Routes > Add route > Published application: the subdomain and the domain of the application, and the service URL http://php:80 (php is 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.

  3. castor prod:init: answer yes to the tunnel and paste its token, or no for the direct HTTPS.

  4. castor prod:start. The first build takes a few minutes.

  5. 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 of cloudflared show Registered tunnel connection when 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 in POSTGRES_PASSWORD, or, on a database without data only, remove the volume: prod-compose down --volumes.
  • php and messenger do not start: they wait for the migrations, which failed. Their error is in prod-compose logs migrations.
  • Redirections to http://, or forms refused: the request does not go through the tunnel. In tunnel mode, Symfony trusts the X-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.json with Use this template: Flex removes the name)
  • Change the database credentials, and set the APP_SECRET of production in its environment (or with bin/console secrets:set)
  • Set MAILER_FROM and the application name (app.name in translations/)
  • Choose a license: Symfony Flex removes LICENSE and makes the project proprietary

License

MIT