jul6art/core-bundle

Symfony core bundle

Maintainers

Package info

github.com/jul6art/core-bundle

Type:symfony-bundle

pkg:composer/jul6art/core-bundle

Transparency log

Statistics

Installs: 221

Dependents: 5

Suggesters: 1

Stars: 2

Open Issues: 0

v2.10.3 2026-08-23 22:54 UTC

README

logo dev in the hood

License Version

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-src closed to 'self', because a library cannot know which hosts your application talks to: an EventSource, an analytics endpoint or a CDN needs csp_policy widened, or it fails silently in the browser. And csp_enforce: true before 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 new in a unit test has no dependency. The property is typed and unassigned, so the first access throws must 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_user impersonation, 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, and null on 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 a NOT NULL violation on insert — a message that mentions neither the trait nor the missing attribute. SoftDeletableTrait and AuditableTrait need no callback, hence no attribute.

Three things worth knowing before you pick:

  • TimestampableTrait and CreatedAtTrait are mutually exclusive. Both declare $createdAt and onPrePersist(), so a class using both is a fatal error, not a merge. Choose CreatedAtTrait when an audit log already records who changed what and when: updated_at and updated_by columns nobody populates stay NULL forever, and a NULL updated_at reads as "never modified".
  • No setter for the timestamps, on purpose, and created_at is mapped updatable: false so even a reflected rewrite never reaches an UPDATE. A fixture that needs a back-dated row uses reflection deliberately.
  • AuditableTrait populates nothing by itself. A trait that guessed the current user would need the security context inside an entity. Call setCreatedBy() / 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']

⚠️ deletedAt is 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\SoftDeleteFilter adds AND <deletedAt column> IS NULL to every query on an entity declaring a deletedAt field, 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(), plus bulkMarkDeletedColumn() / bulkRestoreDeletedColumn() to free UNIQUE columns by appending Util\Strings::DELETED_SUFFIX.
  • Doctrine\DQL\JsonTextFunction exposes JSON_TEXT(field), casting a JSON column to text so a portable LIKE can search it (field::text on 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.added is translated in the organization domain and never reaches domain.

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 FlashTranslator is absent from the container — which is what happens to a controller instantiated with new in 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 through Security::isGranted() on purpose. $token->getRoleNames() returns only the roles actually stored, so an account holding ROLE_ADMIN and granted ROLE_EDITOR through role_hierarchy fails 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_money and fr_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 — NumberToLocalizedStringTransformer parses 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-safe upper() / lower() normalisation for entity setters, plus lowerEmail() / lowerHost() which lowercase everything except a trailing _DELETED_<timestamp> soft-delete marker.
  • Event\PersistenceAbortedException — thrown by EntityListener\AbstractEntityListener when a subscriber aborts a BEFORE_* 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