Language & tooling
- PHP 8.3 (
composer.jsonrequires^8.3). Use modern language features: native backed enums, readonly properties, constructor promotion, first-class attributes. - Add
declare(strict_types=1);at the top of every new file. The codebase is mid-migration (not all legacy files have it yet), so new code should always opt in. - Quality gates run in CI; the per-file diff scripts let you check only what you changed:
bin/phpstan-diff.sh— PHPStan on changed filesbin/phpcs-diff.sh— code style on changed filesbin/static.sh— combined static analysis
Coding style
- Follow PSR-12 plus the Symfony (risky) ruleset.
-
Style is enforced by PHP-CS-Fixer (
.php-cs-fixer.dist.php) and Easy Coding Standard (ECS). Auto-fix before committing: -
House conventions baked into the fixer config: short array syntax (
[]), ordered class elements, strict comparison (===), Yoda style for equality checks, and no arrow functions. Don’t hand-format — let the fixer decide.
Static analysis
- We run PHPStan (with
phpstan-symfony+ bleeding-edge rules) on every PR; config isphpstan.neon. - Newer files must pass at PHPStan level 4 as a hard minimum. Level 8 is the project goal — the config targets it — so aim as high as you can and raise the level on a file as it’s cleaned up. Never lower the bar on a file that already passes higher.
- Migrations, the Kernel, and third-party bundles are excluded from analysis.
Project structure & conventions
- Keep controllers thin; put business logic in services.
- TODO: namespacing and naming conventions.
Modules
Bigger features should live in their own module directory undersrc/ (for example
src/Raffle), with the module owning its own configuration rather than dumping everything
into the global config/:
imports or the
config/services.yaml) so each module stays self-contained.
Communication between modules should be minimal. When one module needs another,
prefer going through a well-defined facade (a small public-facing service that exposes
only what other modules need) rather than reaching into the module’s internals. Keep the
rest of a module’s classes internal to that module.
- Don’t depend on another module’s entities, repositories, or internal services directly — go through its facade where one makes sense.
- A facade is the seam we can keep stable while a module’s internals change.
src/App there are top-level modules
such as src/IGaming, src/Rng, and src/Brdge, each with its own
config/services.yaml. src/IGaming is a reference for a DDD module — it separates
Domain / Application / Infrastructure. New modules should follow this layout.
Services & dependency injection
- Prefer constructor injection and autowiring.
- TODO: conventions for service naming, tags, and configuration.
Doctrine: connections, entities & migrations
We run four separate databases, each with its own DBAL connection and ORM entity manager:| Connection / EM | Entities live in | Mapping prefix |
|---|---|---|
draw | src/App/Entity/Draw (+ Sonata Media on this EM) | App\Entity\Draw |
user | src/App/Entity/User, plus CheckoutIDV and RecurringBilling user entities | App\Entity\User (and module prefixes) |
ticket | src/App/Entity/Ticket | App\Entity\Ticket |
event | src/App/Entity/Event | App\Entity\Event |
%*_database_*% parameters and all use pdo_mysql,
utf8mb4, and a schema_filter of ~^(?!local_)~ (so local_* tables are ignored).
Custom DBAL types (encrypted_data, json, json_array, json_std) and DQL functions
(FIRST, GROUP_CONCAT, DAYOFWEEK, DATE, JSON_CONTAINS) are registered in
config/packages/doctrine.yaml.
Entity conventions
- Put a new entity in the directory for the database it belongs to, matching the mapping prefix above — that determines which entity manager and connection it uses.
- Mapping is attribute-based with the underscore naming strategy.
- An entity belongs to exactly one connection. Do not query across connections in a single query / join across databases — coordinate at the service layer (or via a module facade) instead.
- When injecting an entity manager or repository, make sure you get the one for the right
connection (the
drawEM is the default).
Migrations
Each database has its own migration configuration underconfig/doctrine_migrations/<db>.yaml and its own namespace
(DoctrineMigrations\Draw, \User, \Ticket, \Event). Don’t run the bare
d:m:migrate; use the scripts so every connection is handled:
bin/db_diff, review the generated migration for the right database/namespace
before committing — the diff is generated per connection, so confirm it landed in the
expected namespace and only touches that DB’s schema.
- Never edit a committed migration; create a new one.
- Clean up generated migrations: remove the auto-generated boilerplate Doctrine leaves
behind — the
// this up()/down() migration is auto-generated...comments, leftover@todoplaceholders, and any emptydown()scaffolding — and keep only the SQL you actually mean to run. - Always provide a meaningful
getDescription()so the migration is self-documenting in the migrations list. - Migrations use a custom factory (
App\Doctrine\Migrations\DoctrineAwareMigrationFactory), so migrations can be services and use dependency injection where needed.
API conventions
We use plain Symfony controllers (no API Platform). API controllers extend the sharedApp\Controller\Api\ApiController base, which wraps serialization and JSON responses.
- Routing & docs: declare routes with the
#[Route]attribute and document endpoints with Nelmio / OpenAPI attributes (#[OA\Get],#[OA\Parameter],#[OA\Response],#[Security]). Keep the OpenAPI annotations accurate — they are the published API docs. - Serialization: new modules use the Symfony Serializer (with serialization
groups /
#[Groups]to control exposed fields). JMS Serializer is legacy — only use it in existing endpoints that already depend on it, and only when there’s a concrete reason to (e.g. matching the existing behaviour of that endpoint). Don’t reach for JMS in new code. - Validation: validate input with the Symfony Validator (constraints on request
DTOs/models in
src/App/Model) or Symfony Form types for complex requests. - Errors: throw from the
App\Exceptionhierarchy (ApiException,ApiFormException,ApiDetailedException, …);ApiExceptionSubscriberturns them into the standard JSON shape —{"message": "...", "apiCode": "...", "quantity": ...}(or{"errors": {...}}for form errors). Don’t build error responses ad hoc. - Auth: session-based authentication is the default; OAuth (HWIOAuthBundle) for social
login; JWT (
Authorization: Bearer …) is used by the iGaming module. Guard endpoints with#[IsGranted]/ voters. - Caching: mark cacheable endpoints with the custom
#[CachedEndpoint]attribute (ttl,includeUserContext,warmup).
Encryption of sensitive data
- Store PII / secrets with the custom
encrypted_dataDoctrine type, which encrypts on write and decrypts on read via theApp\Security\Cryptoservice. Use it for sensitive columns instead of storing plaintext.
Testing
Use PHPUnit for unit and functional tests. We are not aiming for 100% coverage. Skip tests for trivial glue code (plain getters/setters, simple DTOs, thin controllers that only delegate). When in doubt, ask: “if this breaks silently, would we notice?” If not, test it.- Cover the meaningful branches and edge cases (zero, negative, empty, boundary values), not just the happy path.
- Keep unit tests fast and free of I/O; use functional tests for HTTP/database integration.
- Tests live in
tests/(module-scoped suites also exist, e.g.src/IGaming/tests,src/Rng/tests). Name test classes*Test.phpand data-provider methodsprovider*. - Tag tests with
@group unit/@group integration. - Build test state with Zenstruck Foundry factories (
tests/Factory) and Stories (tests/Story) rather than hand-rolled fixtures. - The DAMA Doctrine Test Bundle wraps each test in a transaction for isolation — don’t rely on leftover data between tests.
Historically the suite was weak (“there are no working tests” in the old README). The
testing requirement above is a deliberate change of direction — new logic ships with
tests from now on.
Async & messaging
- Asynchronous work currently runs through RabbitMQ via
OldSoundRabbitMqBundle(producers publish, consumer classes handle messages). We do not use Symfony Messenger yet. - Run consumers with the
bin/run_consumers_*scripts; scheduled work runs through the cron runner (php bin/console app:cron:run). - For high-volume work, prefer batch consumers over one-message-at-a-time processing.
Direction of travel: we plan to migrate async messaging to Symfony Messenger.
For new async work, keep handlers thin and framework-agnostic (put the real logic in a
service the consumer just calls) so the move to Messenger is a thin adapter swap rather
than a rewrite.
Design principles & good practices
Aim to comply with SOLID principles and established standards rather than reinventing conventions. These are guidelines, not dogma — favor readable, maintainable code.- SOLID — single responsibility, open/closed, Liskov substitution, interface segregation, dependency inversion.
- PHP-FIG PSRs — follow the relevant standards, especially PSR-1 / PSR-12 (coding style), PSR-4 (autoloading), and PSR-3 (logging).
- HTTP semantics — follow RFC 9110 for methods and status codes, and return errors as RFC 9457 Problem Details.
- Dates & times — serialize and store using ISO 8601; store in UTC.
- Prefer composition over inheritance, keep functions small and single-purpose, and avoid premature abstraction (YAGNI / DRY in moderation).
Code hygiene
- Remove unnecessary comments. Code should be self-explanatory; comment the why, not the what. Delete commented-out code and auto-generated placeholder comments.
- Use early returns / guard clauses instead of deep nesting — handle edge cases up front and let the happy path read top to bottom.
- Leave it nicer than you found it. Aim for clean, readable code: clear names, small methods, and a sensible shape. If a change makes a file harder to read, rethink it.