jul6art / core-bundle
Symfony core bundle
Requires
- php: ^8.5
- doctrine/collections: ^2.2
- doctrine/dbal: ^4.2
- doctrine/doctrine-bundle: ^2.13 || ^3.0
- doctrine/orm: ^3.3
- doctrine/persistence: ^3.4 || ^4.0
- symfony/config: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- symfony/event-dispatcher: ^7.4 || ^8.0
- symfony/http-foundation: ^7.4 || ^8.0
- symfony/http-kernel: ^7.4 || ^8.0
- symfony/property-access: ^7.4 || ^8.0
- symfony/security-bundle: ^7.4 || ^8.0
- symfony/service-contracts: ^3.5
- symfony/translation: ^7.4 || ^8.0
- symfony/yaml: ^7.4 || ^8.0
Requires (Dev)
- fakerphp/faker: ^1.24
- friendsofphp/php-cs-fixer: ^3.68
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^13.2
- rector/rector: ^2.0
- symfony/console: ^7.4 || ^8.0
- symfony/expression-language: ^7.4 || ^8.0
- symfony/form: ^7.4 || ^8.0
- symfony/framework-bundle: ^7.4 || ^8.0
- symfony/lock: ^7.4 || ^8.0
- symfony/mailer: ^7.4 || ^8.0
- symfony/monolog-bundle: ^3.10
- symfony/phpunit-bridge: ^7.4 || ^8.0
- symfony/twig-bridge: ^7.4 || ^8.0
- symfony/twig-bundle: ^7.4 || ^8.0
- symfony/var-dumper: ^7.4 || ^8.0
- twig/twig: ^3.0
Suggests
- ext-sodium: Required by Security\Encryptor and the encrypted_string DBAL type (core.encryption_key)
- fakerphp/faker: Required by the FakerAwareTrait (data fixtures)
- symfony/form: Required by AbstractController::addFormError() and Form\Extension\NumberTypeGroupingExtension
- symfony/framework-bundle: Required by Controller\AbstractController (extends its own AbstractController)
- symfony/mailer: Required by the email_debug handler (Monolog symfony_mailer type)
- symfony/monolog-bundle: To enable the prod logging and the email_debug handlers configured by this bundle
- symfony/security-csrf: Required by Controller\BulkActionRunner — no token manager, no service
README
jul6art/core-bundle
Symfony core bundle
Requirements
- php ^8.5
- symfony ^7.4 || ^8.0
Installation
composer require jul6art/core-bundle
Optional packages
The bundle ships a few opt-in bricks whose dependencies are deliberately left out of the runtime requirements. Install them yourself when you use the matching feature:
| Feature | Package |
|---|---|
Service\Traits\FakerAwareTrait (data fixtures) |
composer require --dev fakerphp/faker |
core.email_debug handler |
composer require symfony/monolog-bundle symfony/mailer |
Security\Encryptor, Doctrine\Type\EncryptedStringType |
ext-sodium (bundled with PHP, but a distribution can omit it) |
Command\PurgeCommand (core:purge) |
composer require symfony/console symfony/lock — and symfony/expression-language only if a policy uses a condition |
Twig\NumberExtension, Twig\PdfAssetExtension |
composer require twig/twig (registered only when Twig is present) |
Form\Extension\NumberTypeGroupingExtension |
composer require symfony/form |
Controller\AbstractController (et addFormError()) |
composer require symfony/framework-bundle symfony/form |
Controller\BulkActionRunner |
composer require symfony/security-csrf (le service n'est enregistré que si le gestionnaire de jetons existe) |
Controller\BulkActionRunner |
composer require symfony/security-csrf (and the ORM) |
Start server
cd my_symfony_application
symfony server:start
Configuration
Every option is optional. email_debug forwards critical logs by email through
Monolog and requires symfony/monolog-bundle plus symfony/mailer.
# config/packages/core.yaml core: email_debug: false email_debug_from: ~ email_debug_title: 'An error occured' email_debug_to: ~ encryption_key: ~
The email_debug* options are also exposed as container parameters, prefixed with
core. (core.email_debug, core.email_debug_from, ...). encryption_key is
deliberately not, so the secret never ends up in the compiled container.
Data at rest
Setting core.encryption_key to a base64-encoded 32-byte key registers
Security\Encryptor (libsodium XSalsa20-Poly1305 secretbox) and the listener that
feeds it to the encrypted_string DBAL type. Leave the key unset and nothing is
registered — an application that encrypts nothing carries no dead service.
# config/packages/core.yaml core: encryption_key: '%env(APP_ENCRYPTION_KEY)%' # never commit the value # config/packages/doctrine.yaml doctrine: dbal: types: encrypted_string: Jul6Art\CoreBundle\Doctrine\Type\EncryptedStringType
#[ORM\Column(type: 'encrypted_string', nullable: true)] private ?string $iban = null;
The ORM only ever sees the plaintext, so forms, validation and change tracking keep working; the ciphertext exists only in the database. Each write uses a fresh nonce, so the same plaintext never produces the same ciphertext twice, and decryption authenticates the payload. Pass the key as an env var: it is read at runtime, not baked into the container.
HTTP security headers
Defence in depth against an XSS escalating into a take-over. Off by default: installing a
utility bundle must not change the responses of an application that did not ask — a lone
X-Frame-Options: DENY breaks any legitimate embedding.
# config/packages/core.yaml core: security_headers: enabled: true csp_enforce: false # start here, always
That much already sends X-Content-Type-Options: nosniff,
Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: DENY, a closed
Permissions-Policy and a one-year Strict-Transport-Security, plus a
Content-Security-Policy-Report-Only.
Only missing headers are filled. A controller that set its own keeps it — a CMS preview
that needs SAMEORIGIN to survive its own iframe still works, without an exception list here.
Tune it per header, and drop one with null:
core: security_headers: enabled: true headers: X-Frame-Options: 'SAMEORIGIN' Strict-Transport-Security: ~ # not sent at all X-Robots-Tag: 'noindex' # extra headers are allowed too csp_policy: "default-src 'self'; connect-src 'self' https://mercure.example.com"
⚠️ Two traps. The default policy keeps
connect-srcclosed to'self', because a library cannot know which hosts your application talks to: an EventSource, an analytics endpoint or a CDN needscsp_policywidened, or it fails silently in the browser. Andcsp_enforce: truebefore reading the violation reports is how a working page stops loading its own assets — report-only first, always.
Captcha
An arithmetic challenge for public forms — register, password reset — where bots submit payloads just to make the application send mail.
// rendering the form return $this->render('security/register.html.twig', [ 'captchaQuestion' => $this->captcha->generate(), // "3 + 5" ]); // handling the submission if (!$this->captcha->validate($request->request->getString('captcha'))) { // refuse, and call generate() again for the next attempt }
core: captcha: operations: ['+', '-', '*'] # default: ['+'] session_key: '_math_captcha_answer'
generate() stores the expected answer in the session and returns the text to display.
validate() checks the submission and consumes the stored answer whatever the outcome, so
a right answer cannot be replayed and a wrong one forces a fresh question — call
generate() again on every re-render, or the next attempt validates against nothing.
Subtractions never ask for a negative answer, since only digits are accepted.
⚠️ Form-only by design. For a JSON client use reCAPTCHA or hCaptcha instead: a challenge whose answer lives in the caller's own session is worth little to an API consumer.
Retention
Annotate an entity with Attribute\Purgeable and core:purge removes the rows whose
retention has expired. The attribute is repeatable, because one entity often needs two
delays:
use Jul6Art\CoreBundle\Attribute\Purgeable; #[Purgeable(field: 'createdAt', interval: '-3 months')] #[Purgeable(field: 'deletedAt', interval: '-1 week', condition: 'entity.isDeleted()')] class AuditLog { … }
bin/console core:purge --dry-run # says what it would remove, removes nothing bin/console core:purge --entity=AuditLog # one entity only bin/console core:purge
Measure before you commit to an interval. --dry-run reports the row count, and a
policy that looks reasonable can turn out to delete most of a table on its first run.
The command exists only when symfony/console and symfony/lock are both installed, and
framework.lock is configured — no lock means no command rather than an unguarded one, since
two concurrent purges would race on the same rows. A prevented concurrent run exits
SUCCESS: a scheduler should not page anyone for a guard working as intended.
It writes no journal of its own. One Event\EntityPurgedEvent is dispatched per removed
row, after the flush, carrying scalars only — by then the entity is detached. Subscribe to it
to record whatever your application needs:
#[AsEventListener(event: EntityPurgedEvent::NAME)] public function onEntityPurged(EntityPurgedEvent $event): void { $this->auditLogger->log('entity.purged', $event->getOrganizationId(), null, $event->getEntityShortName(), $event->getEntityId()); }
# config/packages/core.yaml core: purge: batch_size: 100 # rows flushed at a time; lower it for heavy entities aliases: ['app:purge'] # keeps a legacy name alive so a deployed crontab survives
Performance profiler
A per-request profiler that answers one question: how many queries did this route make? A DBAL middleware counts and fingerprints every statement, a subscriber writes one JSONL record per request, and a data collector adds a panel to the web debug toolbar.
It exists because the N+1 is the first cause of slowness in a Symfony application and the easiest to miss: the code reads well, the tests are green, the page looks fine, and it fires 240 queries.
# config/packages/core.yaml core: performance: enabled: false # OFF by default — see below path: '%kernel.project_dir%/var/performance' # append-only JSONL store rotation: daily # daily | weekly | none max_records: 100000 # oldest file pruned above this cap ignored_route_prefix: 'admin_performance_' # the profiler UI must not measure itself when@dev: core: performance: enabled: true
⚠️ Keep it off in production unless you are deliberately profiling: it appends a record on every request, and the store grows accordingly.
⚠️ The middleware is wired even when enabled is false, and that is deliberate: it resets its
tracker on each request, so a long-running worker cannot accumulate query rows in memory. What the
flag gates is persistence — nothing is written, and the panel stays empty.
What you get:
| Piece | Role |
|---|---|
Performance\Profiler\Middleware\PerformanceMiddleware |
DBAL middleware, always wired, tagged doctrine.middleware |
Performance\Profiler\QueryTracker |
counts total / distinct queries and their time — transactions included (START TRANSACTION, COMMIT, ROLLBACK), so the total matches what the Doctrine collector reports |
Performance\Profiler\PerformanceDataCollector |
the toolbar panel — registered only when FrameworkBundle is installed |
Performance\Store\PerformanceStoreInterface |
the store contract; JsonlFileStore is the shipped implementation |
Performance\Service\DashboardViewBuilder |
aggregates the store into a ready-to-render view (slowest routes, N+1 suspects) |
Performance\Service\PerformanceExporter |
streams the store as CSV or JSON |
core:performance:clear / core:performance:export |
the same, from the console |
The bundle ships no route and no page: jul6art/admin-bundle provides the dashboard for
back-office projects, and any application can render DashboardViewBuilder::build() its own way.
Service traits
Six traits in Service\Traits inject one cross-cutting dependency each, by setter
(#[Required]), so a service can pick up what it needs without growing its constructor
signature — and without every subclass having to forward the arguments.
| Trait | Gives you |
|---|---|
EntityManagerAwareTrait |
$this->entityManager |
EventDispatcherAwareTrait |
$this->eventDispatcher |
FlashBagAwareTrait |
$this->flashBag |
TranslatorAwareTrait |
$this->translator |
FakerAwareTrait |
$this->faker (dev only — composer require --dev fakerphp/faker) |
TokenStorageAwareTrait |
$this->tokenStorage, plus the three resolvers below |
⚠️ Setter injection means a service built with
newin a unit test has no dependency. The property is typed and unassigned, so the first access throwsmust not be accessed before initialization— far from the cause. Either call the setter in the test, or resolve the service from the container.
Who is acting, and on whose behalf
TokenStorageAwareTrait answers the question an audit trail, a created_by column or a
per-tenant listener keeps asking:
$this->getCurrentUserOrNull(); // ?UserInterface $this->getCurrentUserIdOrNull(); // ?int — the account the request runs as $this->getOriginalUserIdOrNull(); // ?int — the account that started an impersonation
getCurrentUserIdOrNull() reads getId() when the user class declares one — UserInterface
does not, so a user without an identifier yields null rather than an error, and a non-numeric
identifier (a UUID) yields null too.
⚠️ During a
_switch_userimpersonation,getCurrentUserIdOrNull()returns the impersonated account. That is usually what you want, and it is exactly what makes an audit trail misleading if you stop there: the record says the user did something an administrator did in their name.getOriginalUserIdOrNull()returns the administrator, andnullon a genuine login — record both.
Entity traits
Five traits in Entity\Traits, each mapping its own columns. Nothing to configure — using one
is the whole installation.
| Trait | Columns | What sets them |
|---|---|---|
IdTrait |
id (IDENTITY) |
the database |
TimestampableTrait |
created_at, updated_at |
#[ORM\PrePersist] / #[ORM\PreUpdate] |
CreatedAtTrait |
created_at |
#[ORM\PrePersist] |
SoftDeletableTrait |
deleted_at |
an explicit softDelete() call |
AuditableTrait |
created_by, updated_by |
your code, through the setters |
#[ORM\Entity] #[ORM\HasLifecycleCallbacks] class Page { use IdTrait; use SoftDeletableTrait; use TimestampableTrait; }
⚠️
#[ORM\HasLifecycleCallbacks]on the entity is not optional for the two timestamp traits. Doctrine ignores#[ORM\PrePersist]/#[ORM\PreUpdate]without it, and says nothing: the columns are created and never filled, so the failure surfaces later as aNOT NULLviolation on insert — a message that mentions neither the trait nor the missing attribute.SoftDeletableTraitandAuditableTraitneed no callback, hence no attribute.
Three things worth knowing before you pick:
TimestampableTraitandCreatedAtTraitare mutually exclusive. Both declare$createdAtandonPrePersist(), so a class using both is a fatal error, not a merge. ChooseCreatedAtTraitwhen an audit log already records who changed what and when:updated_atandupdated_bycolumns nobody populates stay NULL forever, and a NULLupdated_atreads as "never modified".- No setter for the timestamps, on purpose, and
created_atis mappedupdatable: falseso even a reflected rewrite never reaches anUPDATE. A fixture that needs a back-dated row uses reflection deliberately. AuditableTraitpopulates nothing by itself. A trait that guessed the current user would need the security context inside an entity. CallsetCreatedBy()/setUpdatedBy()from the service that owns the write, and store something a human can read in a listing — an email, not a numeric id.
Soft delete and UNIQUE columns
A soft-deleted row keeps its values, so a UNIQUE column blocks the next row that wants the same one: a user deleted and re-created with the same email fails to insert. The trait carries the two helpers that free the value and give it back:
$user->setEmail(Strings::markDeleted($user->getEmail())); // ada@x.test_DELETED_1755… $user->softDelete(); // …later $user->setEmail((string) Strings::restoreDeleted($user->getEmail())); // ada@x.test $user->restore();
Both are idempotent: marking an already-marked value returns it unchanged, restoring a value with no suffix returns it as it is, and only a trailing marker followed by digits is stripped.
They live in Util\Strings, next to the DELETED_SUFFIX constant that defines the convention,
and not on the trait. Two reasons: they are string operations rather than entity behaviour,
and PHP 8.5 deprecates calling a static trait method on the trait itself — a rule that nothing
enforces at a call site.
The trait only stores a date. Hiding the row is the application's job — register
Doctrine\SoftDeleteFilter (below) so Doctrine excludes soft-deleted rows from every query, and
use Service\CascadeSoftDeleteHelper to carry the deletion down to children. Without one of the
two, a soft-deleted row keeps showing up.
Serialization groups do not belong in a trait
An attribute cannot vary from one entity to the next, so a trait carrying #[Groups] ends up
holding the union of every consumer's groups — and has to be edited each time a new resource
is exposed, which is exactly what a bundle cannot accept. None of these traits declares one.
Declare them per class instead, in config/serializer/ (loaded by convention as soon as the
directory exists):
# config/serializer/timestampable.yaml App\Entity\Page: attributes: createdAt: groups: ['page:read'] deletedAt: groups: ['page:read']
⚠️
deletedAtis usually what a data table needs to grey a row out and to decide whether it offers restore or delete. Forgetting its group is a visible bug, and a silent one: the property simply disappears from the response.
Doctrine filters and query helpers
Three independent bricks, all opt-in from the application side. The first two are what actually
hide a row that SoftDeletableTrait has marked:
# config/packages/doctrine.yaml doctrine: orm: filters: soft_delete: class: Jul6Art\CoreBundle\Doctrine\SoftDeleteFilter enabled: true dql: string_functions: JSON_TEXT: Jul6Art\CoreBundle\Doctrine\DQL\JsonTextFunction
Doctrine\SoftDeleteFilteraddsAND <deletedAt column> IS NULLto every query on an entity declaring adeletedAtfield, and leaves the others alone. The column name comes from the mapping, so both naming strategies work.Service\CascadeSoftDeleteHelper(registered automatically when DoctrineBundle is enabled) carries the DQL UPDATE patterns for propagating a soft delete to children:cascadeSoftDelete(),nullifyForeignKey(),cascadeRestore(), plusbulkMarkDeletedColumn()/bulkRestoreDeletedColumn()to free UNIQUE columns by appendingUtil\Strings::DELETED_SUFFIX.Doctrine\DQL\JsonTextFunctionexposesJSON_TEXT(field), casting a JSON column to text so a portableLIKEcan search it (field::texton PostgreSQL,CAST(field AS CHAR)elsewhere).
Controllers
Controller\AbstractController carries the redirect-and-flash vocabulary a controller repeats
on every write. Each helper flashes a message already translated in its own domain, so a
controller never names a domain and never injects the translator just to say "saved".
final class UserController extends AbstractController { public function edit(Request $request, User $user): Response { // … return $this->redirectBackWithSuccess($request, 'user.updated', 'user_index'); } }
Three redirects worth telling apart:
| Helper | Where it lands |
|---|---|
redirectWithSuccess() / redirectWithError() |
a named route, with a flash |
redirectBack() / redirectBackWithSuccess() |
the Referer when it is same-origin, otherwise a fallback route — for an entity reachable from several screens, it is the only thing that knows where the user came from. A Referer on another host is ignored rather than trusted: following it would be an open redirect. |
redirectAfterSave() |
the detail page, or an empty creation form when the request carries _after_save=new — the "save and create another" workflow. Asking for it without a creation route falls back rather than failing. |
redirectAfterDelete() |
always the index, ignoring the Referer on purpose: after a soft delete, the detail page it points at would 404. |
Plus addSuccessFlash() / addErrorFlash() / addWarningFlash(), and addFormError() — which
pre-translates, because form_errors(form) looks a key up in the validators domain and renders
it raw when it is missing.
core: flash: default_domain: 'messages' domain_map: 'organization.domain.': 'domain' # longer prefix first 'organization.': 'organization' 'user.': 'user'
⚠️ The map's order is significant. The first matching prefix wins, so a longer prefix must be declared before the shorter one it starts with — otherwise
organization.domain.addedis translated in theorganizationdomain and never reachesdomain.
Configure nothing and you get plain Symfony behaviour: every flash in the default domain. That is why an application can adopt this base class before deciding how to split its translations.
When the domain belongs to the screen, not to the key
The map above assumes keys carry their domain as a prefix. Plenty of applications do the
opposite: one catalogue per screen, and short keys inside it — edit.success in profile,
invalid_data in report. No prefix map can express that, because the domain is not a function
of the key. Override translationDomain() instead, and map nothing:
final class ProfileController extends AbstractController { protected function translationDomain(): string { return 'profile'; // flashes, form errors and trans() alike } public function edit(Request $request): Response { // … return $this->redirectWithSuccess('profile_show', 'edit.success'); // → domain 'profile' } }
A controller's own domain wins over the map, so a key that happens to look like a mapped prefix still lands where the controller said.
trans() translates in that same domain without flashing anything — the message of a
createNotFoundException(), or a string interpolated into another message, lives in the same
catalogue, and it was usually the last reason to inject the translator:
throw $this->createNotFoundException($this->trans('user.not_found'));
⚠️ Both helpers degrade to the raw key when
FlashTranslatoris absent from the container — which is what happens to a controller instantiated withnewin a unit test. Assert on behaviour, not on the message, in those tests, or boot the container.
Bulk actions
Controller\BulkActionRunner is the data-table bulk-action pattern in one place: validate the
CSRF token, parse ids[], load every row in one query, check the voter row by row, and run a
business callable on each.
#[Route('/bulk-publish', methods: ['POST'])] #[IsGranted(PermissionCodes::CMS_PAGE_PUBLISH)] public function bulkPublish(Request $request, BulkActionRunner $runner): Response { $count = $runner->run($request, Page::class, PageVoter::PUBLISH, function (Page $page): void { if ($page->isPublished()) { throw new \DomainException('Already published.'); // this row only } $this->pageService->publish($page); }); return $this->redirectWithSuccess('cms_page_index', 'cms.page.bulk_published', ['%count%' => $count]); }
The return value is the number of rows the action actually ran on — what a flash should report.
Throw \DomainException to refuse one row. It is caught per row, so a rule refusing one
invoice does not cost the other twenty-four. Any other throwable rolls the whole batch back:
a database error signals corruption that must not leave a half-persisted state. The batch runs in
a single enveloping transaction rather than one per row.
runOnSoftDeleted() is the restore variant: it suspends the soft-delete filter for the fetch —
the rows would be invisible otherwise — and skips anything not actually deleted, so restoring an
already-active row is a silent no-op. It expects the filter to be named soft_delete; pass a
different name to the constructor if yours differs.
⚠️ Keeping
#[IsGranted]on the route is not optional. The per-row voter check inside the runner is the second guard, not the first: without the attribute, an aggregate endpoint answers to anyone logged in.
Voters
Security\Voter\AbstractVoter reduces a voter to its business rules. A concrete voter
states three things — the attributes it carries, the subject types it applies to, how it
decides — and the base class handles the rest: Symfony's two caching hooks, the
anonymous-visitor guard, and the role lookup.
use Jul6Art\CoreBundle\Security\Voter\AbstractVoter; use Symfony\Component\Security\Core\User\UserInterface; final class GalleryVoter extends AbstractVoter { public const string VIEW = 'GALLERY_VIEW'; public const string EDIT = 'GALLERY_EDIT'; protected function attributes(): array { return [self::VIEW, self::EDIT]; } protected function subjects(): array { return [Gallery::class]; } protected function decide(string $attribute, mixed $subject, UserInterface $user): bool { if (!$subject instanceof Gallery) { return false; } return match ($attribute) { self::VIEW => $subject->isPublished() || $this->owns($subject, $user), self::EDIT => $this->owns($subject, $user) || $this->hasRole('ROLE_ADMIN'), default => false, }; } }
What the base class gives you:
| Member | Role |
|---|---|
attributes() |
abstract — the attributes carried, listed explicitly. Feeds supportsAttribute(), which Symfony caches: the voter is never called again for an attribute it does not carry. |
subjects() |
abstract — the subject types. Feeds supportsType(), cached on the type name. Return [] when the decision rests on no entity (a dashboard, a global listing). |
decide() |
abstract — the rules, with a guaranteed non-anonymous $user. |
supportsSubject() |
Instance-level counterpart of supportsType(); override it when the rule is finer than a type. |
hasRole() |
Role of the signed-in account, inheritance included. |
setSecurity() |
#[Required] setter, so a concrete voter keeps its constructor for its own dependencies. |
hasRole()goes throughSecurity::isGranted()on purpose.$token->getRoleNames()returns only the roles actually stored, so an account holdingROLE_ADMINand grantedROLE_EDITORthroughrole_hierarchyfails a raw check and passes this one. If you are replacing hand-written role checks, expect verdicts to change wherever a role was inherited rather than stored.
A missing subject is accepted (supportsType('null') is true), because an attribute that
carries no entity — CREATE, LIST — is a first-class case. Guard the type inside
decide() when an attribute does need its entity, as the example above does.
Number formatting
One service, so a figure looks the same in an HTML view, a PDF and a JSON payload — instead of
each template choosing its own number_format() arguments.
{{ invoice.total|format_number }} {# 1 234,56 #}
{{ invoice.total|format_number(0) }} {# 1 235 #}
{{ invoice.total|format_money('EUR') }} {# 1 234,56 EUR #}
{{ line.vatRate|format_percent }} {# 20 % #}
public function __construct(private readonly NumberFormatter $formatter) {} // … $this->formatter->formatMoney($invoice->getTotal(), 'EUR');
core: number_format: decimal_separator: ',' thousands_separator: ~ # default: a non-breaking space decimals: 2
The defaults follow the French / Luxembourg convention. The thousands separator is a non-breaking space on purpose: a regular one lets a PDF renderer wrap a number across two lines. The percent sign is glued the same way.
Nothing to format returns an empty string, never a 0 or a dash — so the template decides:
{{ value|format_number ?: '—' }}.
The filters are also registered as
fr_number,fr_moneyandfr_percent. Those are historical names kept for existing templates; use the neutral ones in new code.
PDF assets
asset() returns an HTTP URL relative to the current request. dompdf does not fetch remote
URLs in production and has no base to resolve a schemeless relative one — so the image
silently never loads. These two helpers are the way around it.
{# filesystem path, when dompdf may read the directory #} <img src="{{ pdf_image_path(organization.logoPath) }}"> {# base64 data: URI, which no chroot or isRemoteEnabled setting can block #} <img src="{{ pdf_image_data_uri(organization.logoPath) }}">
core: pdf: public_dir: '%kernel.project_dir%/public'
Prefer the data URI for small images — logos, headers — at the cost of roughly a third more
HTML weight; prefer the path when a filesystem location is what is wanted. Both return null
on an empty input, so a template keeps its {% if %} unchanged.
⚠️
pdf_image_data_uri()refuses a file under 100 bytes. A truncated upload would otherwise produce a well-formed URI that dompdf renders as a white square — worse than no image, because nothing signals the failure.
Form bricks
Form\Transformer\StripWhitespaceTransformer reconciles an input mask with a fixed-length
column. A mask like 000 000 000 00000 (SIRET) posts the spaces it drew, and Assert\Length
then rejects the value for being too long:
$builder->get('siret')->addModelTransformer(new StripWhitespaceTransformer(digitsOnly: true)); $builder->get('iban')->addModelTransformer(new StripWhitespaceTransformer());
digitsOnly drops everything that is not a digit; the default drops whitespace only, so an
IBAN keeps its letters. The displayed value is left untouched — the mask redraws itself on
connect. An emptied field reaches the entity as null, not '', so a nullable column does not
end up storing an empty string no Assert\NotBlank would catch.
Form\Extension\NumberTypeGroupingExtension turns on thousands grouping for every
NumberType at once, so a quantity renders as 1 234,56 rather than 1234.56:
core: form: number_grouping: true
⚠️ Opt-in, and deliberately so: it changes how every numeric field of the application looks, which is not a decision a bundle should make on installation. Submission stays backward compatible —
NumberToLocalizedStringTransformerparses a grouped value as readily as an ungrouped one — and a single field can still opt out with'grouping' => false, for a numeric identifier that must not be grouped.
Utilities
Util\Strings— UTF-8-safeupper()/lower()normalisation for entity setters, pluslowerEmail()/lowerHost()which lowercase everything except a trailing_DELETED_<timestamp>soft-delete marker.Event\PersistenceAbortedException— thrown byEntityListener\AbstractEntityListenerwhen a subscriber aborts aBEFORE_*event, so the refused write never reaches the database.
Quality assurance
composer qa # coding standards, Rector, static analysis and tests composer test # PHPUnit composer phpstan # PHPStan, level max composer cs # PHP-CS-Fixer, writes the fixes composer rector # Rector, writes the fixes
cs-check and rector-check are the read-only variants used by the CI.
License
The Core Bundle is open-sourced software licensed under the MIT license.
© 2026 jul6art
