Symfonicat cat wizard logoSymfonicat/ core
symfonicat/core

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.

129core source files
16Doctrine entities
20runtime services
3native discovery roots

# 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.

REQUESTHost + pathexample.com/products/42
CATALOGRuntimeConfigYAML-backed object graph
COMPOSITIONScope stackparcel · env · module · middleware
RESPONSERuntimeRendererTwig through PSR-15
Domain

Bare host, top-level parcel, modules, middleware, env, and child subdomains.

Subdomain

An affix scoped to an optional domain. The same affix can be reused on multiple domains.

Endpoint

A repeatable path-argument pattern, optionally enforced against one domain or subdomain.

Parcel

A package-scoped frontend entry and its base environment values.

Module

An attachable PHP + JavaScript feature callable only inside an authorized runtime scope.

Application

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.

bash
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.lock
Local host routing

Add 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.

dockerfile
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"]
phpFrankenPHP worker, Caddy, HTTP/1–3, Mercure, native extensions, ports 80/443 TCP and 443 UDP.
npmOne-shot npm ci plus Encore build with parcel and module discovery.
messenger-worker × 8Redis-backed async workers with independent consumer names, memory, time, and failure limits.
PostgreSQL 16Doctrine control-plane storage and health-checked persistence.
Redis 7Sessions, locks, cache, Messenger queues, and rate-limit support.
CaddyInternal or exported ACM TLS, Mercure, Brotli precompression, Zstd/Gzip, and module-body decompression.

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

  1. PublicRuntimeSubscriber · priority 512Rejects reserved paths, resolves Domain and Subdomain from the host, validates signed module requests, and selects the most-specific matching Endpoint.
  2. Normal Symfony routingRegular routes remain authoritative. Runtime catch-all routes use priority -1000 and require an explicit request attribute.
  3. RuntimeRendererChooses the active entity, resolves an override or default Twig shell, issues a signed module context, and renders domain/subdomain/endpoint/application variables.
  4. RuntimeMiddlewareRunnerBuilds a deduplicated PSR-15 chain from domain → subdomain → endpoint and converts between HttpFoundation and PSR-7.

Template precedence

text
templates/{domain,subdomain,endpoint}/overrides/{id}.html.twig
templates/{domain,subdomain,endpoint}/main.html.twig

For 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.

yaml
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-app
Lookup rule

SubdomainService 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.

php
$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.

twig
{{ 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
<?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

javascript
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
1String helpers.json(), .html(), and .log()
2Brotli bodyJSON compressed in-browser with quality 6
3Signed scopeContext id + HMAC token headers
4Guarded response404 unless attached to current scope

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.

  1. Domain middlewareruns whenever a domain is active
  2. Subdomain middlewareadds to subdomain and endpoint renders
  3. 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.

yaml
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 }
  1. Parcelbase defaults for the selected shell
  2. Domaintenant-wide overrides
  3. Subdomainsubdomain parcel + scoped overrides
  4. Endpointpath-context overrides
  5. Applicationhighest priority in application context
twig
{{ 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.

1. Lock gate

AdminLockSubscriber runs at priority 4096. Without symfonicat.lock, core routes return a cache-disabled, noindex 404.

2. Password

Symfony form login uses AdminUserProvider, a five-minute user snapshot cache, CSRF, and 10 attempts per five minutes.

3. TOTP

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.

4. CRUD → YAML

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.

PUBLIC REQUESTSYAML → RuntimeConfig

No database lookup is required to resolve domains, subdomains, endpoints, parcels, modules, middleware, applications, or env.

CORE REQUESTSDoctrine → YAML

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.

text
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

  1. DiscoverNativeDiscoveryService finds child directories under every native/ext root using exact and glob package patterns.
  2. Stagebin/native-build copies each directory into /usr/src/php/ext/<name>.
  3. Configureconfig.m4 is inspected for PHP_ARG_ENABLE or PHP_ARG_WITH.
  4. CompileAll discovered names are passed to docker-php-ext-install. Built-ins include the C remove_string function and the native module_routes scanner.

Go / FrankenPHP path

  1. DiscoverDirectories under every native/go root must include Go source; module-backed entries expose a path in go.mod.
  2. Generate PHP bridgeFiles containing export_php:function run through frankenphp extension-init, producing C headers, arginfo, stubs, and bridge code.
  3. Resolve modulesgo mod tidy validates each module before build flags are assembled.
  4. Compile serverxcaddy rebuilds FrankenPHP with CGO, Mercure, Vulcain, Caddy Brotli, project/vendor modules, and smaller no-database build tags.
reverse

Exports symfonicat_reverse(string): string from Go into PHP using FrankenPHP Zend-string conversion.

brotli

Registers Caddy handlers for build-file precompression and transparent module request decompression.

module_routes

A C extension reads Composer metadata and PSR-4 maps to accelerate discovery of opted-in module controllers.

remove_string

A conventional PHP C extension used by the generated native service proxy example.

Service proxy compilation

php
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:update

Synchronize Doctrine plus discovered parcels, modules, domains, subdomains, and middleware rows.

symfonicat:load

Load config/packages/symfonicat.yaml into the database-backed admin model.

symfonicat:admin:create

Create or update an administrator, password hash, TOTP secret, and provisioning QR.

symfonicat:admin:yaml:dump / load

Explicitly export or import the complete non-admin Symfonicat table set.

symfonicat:application:build

Generate Electron skeletons for every configured Application.

symfonicat:data:webpack

Emit 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:purge

Drop every symfonicat_* table across PostgreSQL, MySQL, or SQLite.

symfonicat:dns:data

Produce DNS-related runtime data.

symfonicat:mercure:test / redis:debug

Exercise realtime and cache infrastructure.

# AWS deployment

The repository includes an end-to-end container path rather than generic cloud notes.

bin/ecr

Builds the runtime target and pushes a tag to private ECR.

bin/ecs

Creates or updates a Fargate cluster, task definition, service, networking, execution role, and optional load-balancer target.

bin/route53

Creates/reuses a hosted zone and publishes apex plus wildcard A records to the running task IP.

bin/cert

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.

Request pathSubscribers → services → runtime controller → renderer → middleware → Twig.
Control pathCore controllers → forms → entities/repositories → flush subscriber → YAML.
Package pathDiscovery → schema sync → module/parcel catalogs → Encore/native compilation.
Attributes3 files
Commands15 files
Contracts & dependency injection5 files
Controllers16 files
Entities16 files
Event subscribers6 files
Forms23 files
Middleware4 files
Repositories & security11 files
Services20 files
Twig extensions10 files

# Testing and the rest of the README

bash
docker exec -it php ./bin/phpunit

The 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@dunglas at the zoo, included with the Symfonicat repository