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/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
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 |
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
Soft delete
Three independent bricks, all opt-in from the application side:
# 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).
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
