
Symfonicat
One Symfony runtime. Many domains, packages, and native capabilities.
Symfonicat is a Symfony 8 multi-tenant frontend runtime and administration system. It composes host-aware shells from domains, reusable subdomains, endpoint patterns, parcels, modules, PSR-15 middleware, scoped environment values, generated Electron applications, package assets, and compiled C or Go extensions.
# Mental model
Symfonicat separates the public data plane from the administration control plane. Public traffic never depends on live CRUD queries: it builds an in-memory catalog from config/packages/symfonicat.yaml. The protected /core UI edits Doctrine rows, then automatically writes those rows back to the same YAML file.
Bare host, top-level parcel, modules, middleware, env, and child subdomains.
An affix scoped to an optional domain. The same affix can be reused on multiple domains.
A repeatable path-argument pattern, optionally enforced against one domain or subdomain.
A package-scoped frontend entry and its base environment values.
An attachable PHP + JavaScript feature callable only inside an authorized runtime scope.
A selected URL context used to generate a buildable Electron shell.
# Quick start
The repository is self-contained: clone it, build the multi-stage image, synchronize discovered package data, load the YAML catalog, create a two-factor administrator, and unlock the core UI.
git clone https://github.com/symfonicat/core symfonicat
cd symfonicat
docker compose up -d --build
docker exec -it php bin/console symfonicat:schema:update
docker exec php bin/console symfonicat:load
docker exec -it php bin/console symfonicat:admin:create <email>
touch symfonicat.lockAdd 127.0.0.1 example.com and 127.0.0.1 subdomain1.example.com to /etc/hosts. The lock file intentionally makes /core look like a 404 until it is enabled.
# Docker and FrankenPHP stack
The Dockerfile starts from dunglas/frankenphp:builder-php8.4. Its base stage installs the C toolchain, Autoconf, Node 22, Composer, xcaddy, inotify, and the PHP extensions required by the application. It downloads FrankenPHP source because Symfonicat recompiles the server with project and vendor Go modules.
FROM dunglas/frankenphp:builder-php8.4 AS php-base
# Installs build tools, Node 22, Composer, xcaddy and PHP extensions.
# Copies source and compiles project + vendor native code.
RUN /symfonicat/bin/native-build
FROM php-base AS npm
FROM php-base AS runtime
COPY Caddyfile /etc/frankenphp/Caddyfile
ENTRYPOINT ["/usr/local/bin/symfonicat-entrypoint"]npm ci plus Encore build with parcel and module discovery.The entrypoint writes the Caddy TLS snippet from ECS certificate environment values when present, otherwise uses internal TLS. Set SYMFONICAT_NATIVE_WATCH=1 to replace the normal server process with the native rebuild watcher.
# Public request lifecycle
- PublicRuntimeSubscriber · priority 512Rejects reserved paths, resolves Domain and Subdomain from the host, validates signed module requests, and selects the most-specific matching Endpoint.
- Normal Symfony routingRegular routes remain authoritative. Runtime catch-all routes use priority
-1000and require an explicit request attribute. - RuntimeRendererChooses the active entity, resolves an override or default Twig shell, issues a signed module context, and renders domain/subdomain/endpoint/application variables.
- RuntimeMiddlewareRunnerBuilds a deduplicated PSR-15 chain from domain → subdomain → endpoint and converts between HttpFoundation and PSR-7.
Template precedence
templates/{domain,subdomain,endpoint}/overrides/{id}.html.twig
templates/{domain,subdomain,endpoint}/main.html.twigFor subdomains, the override id is the public affix. For domains and endpoints, it is the clean entity id. The renderer also exposes a signed request object used by frontend modules.
# Domains and reusable subdomains
A Domain id is always a bare hostname. DomainService finds the longest configured suffix, so both example.com and any host beneath it resolve to that domain. Public reads come from YAML; /core reads the database repository.
A Subdomain has an internal integer id, a public affix, and an optional Domain relation. Uniqueness is domain + affix, not affix alone. That means app.example.com and app.example.org can be distinct rows with different parcels, modules, middleware, and environment values while reusing the same public label.
symfonicat:
symfonicat_domain:
-
id: example.com
parcel_id: acme/site/domain
-
id: example.org
parcel_id: acme/site/domain
symfonicat_subdomain:
-
id: 1
affix: app
domain_id: example.com
parcel_id: acme/site/app
-
id: 2
affix: app
domain_id: example.org
parcel_id: acme/site/partner-appSubdomainService first extracts the leading host affix, then asks RuntimeConfig for the row keyed by domain|affix. A domainless row is a separate fallback lookup, not a collision.
# Endpoints
Endpoints turn paths into runtime targets. arguments is an ordered list of literals or * wildcards. catch allows trailing segments; an empty argument list with catch enabled acts as a wildcard for the current path. Endpoint rows can enforce a Domain or a Subdomain, then contribute their own parcel, modules, middleware, and env.
$endpoint = (new Endpoint())
->setId('acme/product')
->setArguments(['products', '*'])
->setCatch(false)
->setEnforce(Endpoint::ENFORCE_SUBDOMAIN)
->setDomain($domain)
->setSubdomain($appSubdomain)
->setParcel($productParcel);
// Matches /products/42 only inside app.<domain>.
// Longer argument arrays are evaluated before shorter ones.# Parcels and assets
A Parcel is the package-scoped frontend shell unit. Composer packages opt in with extra.symfonicat: true and place entries under assets/parcel/<name>/index.js. PackageDiscoveryService produces ids such as symfonicat/core/domainparcel; ParcelService synchronizes those paths and clears entity references when stale package parcels disappear.
Private build entries
symfonicat:data:webpack reads the active catalog, scans root and installed vendor packages, and passes parcel/module entries to webpack.symfonicat.js. Encore produces versioned bundles under public/build, with async WebAssembly enabled.
Public files
symfonicat_asset(path) checks the active subdomain, then domain, then public/default. The admin file screen uploads domain/subdomain/default assets through validated form models.
{{ encore_entry_script_tags('parcel/' ~ domain.parcel.id) }}
<link rel="icon" href="{{ symfonicat_asset('favicon.svg') }}">
<link rel="icon" href="{{ symfonicat_asset('favicon.svg', subdomain) }}"># Symfonicat modules
Modules are package-discovered features attachable to Domains, Subdomains, or Endpoints. The backend route exists only when its module is attached to the active runtime scope. This is enforced twice: signed request context validation in PublicRuntimeSubscriber, then attachment checks inside AbstractModuleController.
PHP module API
<?php
namespace Acme\Feature\Module;
use Symfonicat\Attribute\Module;
use Symfonicat\Attribute\ModuleRoute;
use Symfonicat\Controller\AbstractModuleController;
use Symfony\Component\HttpFoundation\Response;
#[ModuleRoute(path: '/m/acme/catalog')]
final class CatalogController extends AbstractModuleController
{
#[Module(permission: 'PUBLIC_ACCESS')]
public function product(): Response
{
$payload = $this->requestStack
?->getCurrentRequest()
?->attributes->get('module_json');
return $this->json([
'ok' => true,
'productId' => $payload['productId'] ?? null,
'domain' => $this->domainService->load()?->getTld(),
]);
}
#[Module]
public function card(): Response
{
return $this->html('<article>Rendered by PHP</article>');
}
}#[ModuleRoute] targets a controller class and defaults to /m/<composer-package>. #[Module] targets a public action and defaults to PUBLIC_ACCESS. During container compilation, Symfonicat scans module actions and makes typed action dependencies callable.
JavaScript bridge
const mod = 'acme/catalog/main'
mod.log('module active')
// POST Brotli-compressed JSON to /m/acme/catalog/main/product
const product = await mod.json('product', {
productId: 42,
})
// Request an HTML fragment from the same attached module.
const card = await mod.html('card', {
productId: 42,
})
document.querySelector('#product-card').innerHTML = card.json(), .html(), and .log()Caddy’s brotli_decompress Go handler expands every /m/* body before PHP. SymfonicatModuleSubscriber decodes JSON into the module_json request attribute. Empty JSON responses normalize to {}; HTML stays text.
# PSR-15 middleware
Every service implementing Psr\Http\Server\MiddlewareInterface is automatically tagged symfonicat.middleware. The YAML catalog stores class-backed middleware rows and join tables for each scope.
- Domain middlewareruns whenever a domain is active
- Subdomain middlewareadds to subdomain and endpoint renders
- Endpoint middlewareadds only for endpoint renders
The runner deduplicates by id/class, preserves scope order, passes Symfonicat request attributes into PSR-7, then converts the final PSR response back to HttpFoundation.
# Environment values
Environment keys are two-level names: an EnvParent such as colors plus an Env such as primary. Scope rows carry the actual string. EnvService recursively merges them and exposes the same grouped structure to Twig and window.env.
symfonicat:
symfonicat_env_parent:
- { id: colors }
symfonicat_env:
- { id: primary, env_parent_id: colors }
symfonicat_domain_env:
- { id: 1, domain_id: example.com, env_id: primary, value: blue }
symfonicat_subdomain_env:
- { id: 1, subdomain_id: 1, env_id: primary, value: green }
symfonicat_endpoint_env:
- { id: 1, endpoint_id: product, env_id: primary, value: purple }- Parcelbase defaults for the selected shell
- Domaintenant-wide overrides
- Subdomainsubdomain parcel + scoped overrides
- Endpointpath-context overrides
- Applicationhighest priority in application context
{{ env('colors.primary') }}
<script>console.log(window.env.colors.primary)</script># Admin interface
The /core control plane is a server-rendered Bootstrap CRUD interface for domains, subdomains, endpoints, parcels, middleware, applications, environment keys/values, files, schema synchronization, and YAML import/export.
AdminLockSubscriber runs at priority 4096. Without symfonicat.lock, core routes return a cache-disabled, noindex 404.
Symfony form login uses AdminUserProvider, a five-minute user snapshot cache, CSRF, and 10 attempts per five minutes.
Each Admin owns a SHA-1, 30-second, six-digit TOTP secret. Verification is bound to the admin and an authorization-header fingerprint in Redis-backed session state.
Doctrine flushes from /core are detected. Non-Admin entity changes automatically dump the ordered symfonicat_* tables to YAML.
The dashboard redirects to Domains. Navigation exposes domains, subdomains, endpoints, parcels, middleware, applications, env, files, YAML dump/load, and logout. Schema update is also exposed as a controller action and reports created, updated, and deleted package rows.
# symfonicat.yaml
This file is both the deployable runtime catalog and the serialized admin configuration. AdminYaml introspects every symfonicat_* table except admins, orders tables by foreign-key dependency, normalizes JSON/booleans, and writes deterministic YAML. Loading runs in a transaction, deletes children before parents, inserts parents before children, validates tables/columns, and resets auto-increment sequences.
No database lookup is required to resolve domains, subdomains, endpoints, parcels, modules, middleware, applications, or env.
Forms operate on repositories. A flush subscriber persists the resulting control-plane state back to YAML.
The catalog contains rows and join rows for parcels, domains, subdomains, endpoints, applications, env parents/keys/values, middleware, modules, and every attachment relation. Composer install runs symfonicat:purge; deployments then rebuild the schema and load clean configuration.
# Native C and Go system
Native code is a first-class package capability. Discovery scans three package layers: the application root, built-in core/, and every installed Composer package opted into Symfonicat. Each layer can ship traditional PHP C extensions and/or Go modules compiled into FrankenPHP.
native/
├── ext/
│ └── <php-extension>/
│ ├── config.m4
│ └── <extension>.c
└── go/
└── <go-module>/
├── go.mod
├── *.go
└── *.stub.php
core/native/{ext,go}/...
vendor/<vendor>/<package>/native/{ext,go}/...C extension path
- Discover
NativeDiscoveryServicefinds child directories under everynative/extroot using exact and glob package patterns. - Stage
bin/native-buildcopies each directory into/usr/src/php/ext/<name>. - Configure
config.m4is inspected forPHP_ARG_ENABLEorPHP_ARG_WITH. - CompileAll discovered names are passed to
docker-php-ext-install. Built-ins include the Cremove_stringfunction and the nativemodule_routesscanner.
Go / FrankenPHP path
- DiscoverDirectories under every
native/goroot must include Go source; module-backed entries expose a path ingo.mod. - Generate PHP bridgeFiles containing
export_php:functionrun throughfrankenphp extension-init, producing C headers, arginfo, stubs, and bridge code. - Resolve modules
go mod tidyvalidates each module before build flags are assembled. - Compile serverxcaddy rebuilds FrankenPHP with CGO, Mercure, Vulcain, Caddy Brotli, project/vendor modules, and smaller no-database build tags.
Exports symfonicat_reverse(string): string from Go into PHP using FrankenPHP Zend-string conversion.
Registers Caddy handlers for build-file precompression and transparent module request decompression.
A C extension reads Composer metadata and PSR-4 maps to accelerate discovery of opted-in module controllers.
A conventional PHP C extension used by the generated native service proxy example.
Service proxy compilation
interface TextToolsInterface
{
#[CompileNative]
public function removeString(string $value, string $needle): string;
}
// NativeProxyCompilerPass detects the attribute and aliases the
// interface to Symfonicat\NativeProxy\Service\TextToolsNativeProxy.
// The proxy calls the compiled C extension function directly:
return \remove_string($value, $needle);#[CompileNative] marks interface/service methods intended for native delegation. NativeProxyCompilerPass detects those methods, locates the matching Symfonicat\NativeProxy class, decorates the original service, and aliases supported App/Native/Symfonicat interfaces to the proxy.
Development watcher
With SYMFONICAT_NATIVE_WATCH=1, bin/native-watch snapshots root/core/vendor native files, prefers recursive inotify, falls back to polling, recompiles on change, and restarts FrankenPHP only after a successful native build. It records backend, PID, snapshot, timestamp, and last changed path in a sentinel file.
# Applications and Electron
An Application selects a Domain, Subdomain, or Endpoint context. Requests carrying the application id expose the entity in Twig and window.application; its environment rows override endpoint values.
symfonicat:application:build clears generated output and creates applications/<application.id>/main.js, package.json, and README.md. It resolves templates/application/overrides/<id>.js.twig before the default template, builds an HTTPS URL from domain + subdomain + endpoint arguments, and configures Electron Builder.
# Console command map
symfonicat:schema:updateSynchronize Doctrine plus discovered parcels, modules, domains, subdomains, and middleware rows.
symfonicat:loadLoad config/packages/symfonicat.yaml into the database-backed admin model.
symfonicat:admin:createCreate or update an administrator, password hash, TOTP secret, and provisioning QR.
symfonicat:admin:yaml:dump / loadExplicitly export or import the complete non-admin Symfonicat table set.
symfonicat:application:buildGenerate Electron skeletons for every configured Application.
symfonicat:data:webpackEmit parcel and module entry metadata for Encore.
symfonicat:discover:{ext,go}:{paths,names}Inspect native directories across root, core, and opted-in vendor packages.
symfonicat:purgeDrop every symfonicat_* table across PostgreSQL, MySQL, or SQLite.
symfonicat:dns:dataProduce DNS-related runtime data.
symfonicat:mercure:test / redis:debugExercise realtime and cache infrastructure.
# AWS deployment
The repository includes an end-to-end container path rather than generic cloud notes.
Builds the runtime target and pushes a tag to private ECR.
Creates or updates a Fargate cluster, task definition, service, networking, execution role, and optional load-balancer target.
Creates/reuses a hosted zone and publishes apex plus wildcard A records to the running task IP.
Requests an exportable ACM apex + wildcard certificate, publishes DNS validation, exports base64 TLS material, then refreshes ECS and DNS.
Defaults are 512 CPU units, 1024 MiB, one public Fargate task on port 443, with default-VPC discovery and an auto-created security group. The entrypoint converts exported certificate environment variables into Caddy-readable files.
# Complete core/src atlas
All 129 files are indexed below. Each group corresponds to an architectural responsibility; every filename links directly to its implementation.
Commands15 files
Command/AdminCreateCommand.php↗Command/AdminDeleteCommand.php↗Command/AdminYamlDumpCommand.php↗Command/AdminYamlLoadCommand.php↗Command/ApplicationBuildCommand.php↗Command/DiscoverExtNamesCommand.php↗Command/DiscoverExtPathsCommand.php↗Command/DiscoverGoNamesCommand.php↗Command/DiscoverGoPathsCommand.php↗Command/DnsDataCommand.php↗Command/MercureTestCommand.php↗Command/PurgeCommand.php↗Command/RedisDebugCommand.php↗Command/SchemaUpdateCommand.php↗Command/WebpackModulesDataCommand.php↗Contracts & dependency injection5 files
Controllers16 files
Controller/AbstractModuleController.php↗Controller/Core/ApplicationController.php↗Controller/Core/AuthController.php↗Controller/Core/DashboardController.php↗Controller/Core/DomainController.php↗Controller/Core/EndpointController.php↗Controller/Core/EnvController.php↗Controller/Core/FileUploadController.php↗Controller/Core/MiddlewareController.php↗Controller/Core/ParcelController.php↗Controller/Core/SchemaController.php↗Controller/Core/SubdomainController.php↗Controller/Core/YamlController.php↗Controller/HealthController.php↗Controller/RuntimeController.php↗Controller/TestController.php↗Entities16 files
Entity/Admin.php↗Entity/Application.php↗Entity/ApplicationEnv.php↗Entity/Domain.php↗Entity/DomainEnv.php↗Entity/Endpoint.php↗Entity/EndpointEnv.php↗Entity/Env.php↗Entity/EnvParent.php↗Entity/Middleware.php↗Entity/Module.php↗Entity/Parcel.php↗Entity/ParcelEnv.php↗Entity/Subdomain.php↗Entity/SubdomainEnv.php↗Entity/VendorScopedIdTrait.php↗Event subscribers6 files
Forms23 files
Form/AbstractScopedEnvType.php↗Form/ApplicationEnvType.php↗Form/ApplicationType.php↗Form/DomainEnvType.php↗Form/DomainType.php↗Form/EndpointEnvType.php↗Form/EndpointType.php↗Form/EnvParentType.php↗Form/EnvType.php↗Form/EventSubscriber/CoreYamlDumpSubscriber.php↗Form/Extension/CoreYamlDumpTypeExtension.php↗Form/FileUploadItemType.php↗Form/FileUploadType.php↗Form/MiddlewareReferenceType.php↗Form/MiddlewareType.php↗Form/Model/FileUploadData.php↗Form/Model/FileUploadItemData.php↗Form/ParcelChoiceFormTrait.php↗Form/ParcelEnvType.php↗Form/ParcelType.php↗Form/SubdomainEnvType.php↗Form/SubdomainType.php↗Form/VendorScopedIdFormTrait.php↗Middleware4 files
Repositories & security11 files
Repository/AdminRepository.php↗Repository/ApplicationRepository.php↗Repository/DomainRepository.php↗Repository/EndpointRepository.php↗Repository/EnvParentRepository.php↗Repository/EnvRepository.php↗Repository/MiddlewareRepository.php↗Repository/ModuleRepository.php↗Repository/ParcelRepository.php↗Repository/SubdomainRepository.php↗Security/AdminUserProvider.php↗Services20 files
Service/AdminMfaService.php↗Service/AdminYaml.php↗Service/AffixService.php↗Service/ApplicationService.php↗Service/DomainService.php↗Service/EnvService.php↗Service/FileUploadService.php↗Service/MiddlewareClassProvider.php↗Service/ModuleRequestContextStore.php↗Service/ModuleService.php↗Service/NativeDiscoveryService.php↗Service/PackageDiscoveryService.php↗Service/ParcelService.php↗Service/PathService.php↗Service/PublicFileUploadService.php↗Service/RuntimeConfig.php↗Service/RuntimeMiddlewareRunner.php↗Service/RuntimeRenderer.php↗Service/SchemaSynchronizer.php↗Service/SubdomainService.php↗Twig extensions10 files
# Testing and the rest of the README
docker exec -it php ./bin/phpunitThe test environment lowers password-hashing cost, uses dedicated cache/Doctrine/routing configuration, and the repository includes unit and integration coverage for runtime, admin, modules, native discovery, configuration, templates, middleware, and commands.
Picture of @dunglas at the zoo
