From 2b098f45b08918599a3017381f5fb38c620076f5 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Thu, 4 Jun 2026 17:01:40 +0200 Subject: [PATCH 1/9] chore: remove non-bundle docs and flatten docs/ to single directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove deployment, fixtures, migrations, autocomplete, pdf, and components.html — these document default Symfony or third-party behaviour, not this bundle. Merge docs/frontend/mails.md into docs/mails.md and move all remaining docs to docs/ root. Delete docs/deployment/, docs/development/, and docs/frontend/ subdirectories. Co-Authored-By: Claude Sonnet 4.6 --- docs/{frontend => }/ajax-client.md | 0 docs/{frontend => }/asset-mapper.md | 0 docs/{development => }/audit-trail.md | 0 docs/{development => }/breadcrumb.md | 0 docs/{development => }/button-locations.md | 0 docs/{frontend => }/dark-mode.md | 0 docs/deployment/deployment.md | 52 ----- docs/development/autocomplete.md | 74 ------ docs/development/fixtures.md | 4 - docs/development/migrations.md | 24 -- docs/development/pdf.md | 3 - docs/{development => }/encrypted.md | 0 docs/{development => }/forms.md | 0 docs/{frontend => }/frontend-development.md | 0 docs/frontend/components.html | 234 ------------------- docs/frontend/mails.md | 11 - docs/{frontend => }/installation.md | 0 docs/{development => }/language-switch.md | 0 docs/{development => }/mails.md | 9 + docs/{development => }/menu.md | 0 docs/{frontend => }/no-results.md | 0 docs/{development => }/pagination.md | 0 docs/{frontend => }/stimulus.md | 0 docs/{development => }/title.md | 0 docs/{development => }/uploading-files.md | 0 docs/{development => }/uploading-images.md | 0 docs/{development => }/using-date-pickers.md | 0 27 files changed, 9 insertions(+), 402 deletions(-) rename docs/{frontend => }/ajax-client.md (100%) rename docs/{frontend => }/asset-mapper.md (100%) rename docs/{development => }/audit-trail.md (100%) rename docs/{development => }/breadcrumb.md (100%) rename docs/{development => }/button-locations.md (100%) rename docs/{frontend => }/dark-mode.md (100%) delete mode 100644 docs/deployment/deployment.md delete mode 100644 docs/development/autocomplete.md delete mode 100644 docs/development/fixtures.md delete mode 100644 docs/development/migrations.md delete mode 100644 docs/development/pdf.md rename docs/{development => }/encrypted.md (100%) rename docs/{development => }/forms.md (100%) rename docs/{frontend => }/frontend-development.md (100%) delete mode 100644 docs/frontend/components.html delete mode 100644 docs/frontend/mails.md rename docs/{frontend => }/installation.md (100%) rename docs/{development => }/language-switch.md (100%) rename docs/{development => }/mails.md (76%) rename docs/{development => }/menu.md (100%) rename docs/{frontend => }/no-results.md (100%) rename docs/{development => }/pagination.md (100%) rename docs/{frontend => }/stimulus.md (100%) rename docs/{development => }/title.md (100%) rename docs/{development => }/uploading-files.md (100%) rename docs/{development => }/uploading-images.md (100%) rename docs/{development => }/using-date-pickers.md (100%) diff --git a/docs/frontend/ajax-client.md b/docs/ajax-client.md similarity index 100% rename from docs/frontend/ajax-client.md rename to docs/ajax-client.md diff --git a/docs/frontend/asset-mapper.md b/docs/asset-mapper.md similarity index 100% rename from docs/frontend/asset-mapper.md rename to docs/asset-mapper.md diff --git a/docs/development/audit-trail.md b/docs/audit-trail.md similarity index 100% rename from docs/development/audit-trail.md rename to docs/audit-trail.md diff --git a/docs/development/breadcrumb.md b/docs/breadcrumb.md similarity index 100% rename from docs/development/breadcrumb.md rename to docs/breadcrumb.md diff --git a/docs/development/button-locations.md b/docs/button-locations.md similarity index 100% rename from docs/development/button-locations.md rename to docs/button-locations.md diff --git a/docs/frontend/dark-mode.md b/docs/dark-mode.md similarity index 100% rename from docs/frontend/dark-mode.md rename to docs/dark-mode.md diff --git a/docs/deployment/deployment.md b/docs/deployment/deployment.md deleted file mode 100644 index 96b7331c..00000000 --- a/docs/deployment/deployment.md +++ /dev/null @@ -1,52 +0,0 @@ -# Using deployment - -## Local alias - -You should configure an `alias`: - -``` -dep='symfony php vendor/bin/dep' -``` - -With this you can run `dep` instead of `symfony php vendor/bin/dep`. - -## Configuration - -You will need to fill in 4 variables: - -* `:client`, the `xxx` should be replaced with the name of the client. -* `:project`, the `xxx` should be replaced with the name of the project. -* `:repository`, the `xxx` should be replaced with the url of the git-repo. -* `:production_url`, the `xxx` should be replaced with the production url. -* `:production_user`, the `xxx` should be replaced with the production user. - -Remark: when choosing a name for the project, please don't use generic names -as: site, app, ... as it makes no sense when there are multiple projects for -that client. - -## Deploy commands - -List all possible deploy commands - - dep list - -## Deploy - - dep deploy stage=staging - -## Deploy images and files - -In the deploy.php script we can set the shared directory. -This is how the local and remote server will be able to download or upload the correct directory. - - Example: add('shared_dirs', ['public/files']); - -Please keep in mind that when executing the get or put files command that the files are being replaced. - -Get all files from remote server to local - - dep sumo:files:get - -Put all files from local to remote server - - dep sumo:files:put diff --git a/docs/development/autocomplete.md b/docs/development/autocomplete.md deleted file mode 100644 index d7cdf000..00000000 --- a/docs/development/autocomplete.md +++ /dev/null @@ -1,74 +0,0 @@ -# Autocomplete - -The framework core makes use of `symfony/ux-autocomplete` to provide autocomplete functionality. - -## Usage - -To enable autocomplete functionality, you need to add the `autocomplete` parameter to your form field. All other options -are optional. - -```php -class AnyForm extends AbstractType -{ - public function buildForm(FormBuilderInterface $builder, array $options): void - { - $builder - ->add('food', EntityType::class, [ - 'class' => Food::class, - 'placeholder' => 'What should we eat?', - 'autocomplete' => true, - ]) - - ->add('portionSize', ChoiceType::class, [ - 'choices' => [ - 'Choose a portion size' => '', - 'small' => 's', - 'medium' => 'm', - 'large' => 'l', - 'extra large' => 'xl', - 'all you can eat' => '∞', - ], - 'autocomplete' => true, - ]) - ; - } -} -``` - -## Using Ajax - -If you want to use ajax to fetch the autocomplete options, you need to create a new form type with the -`AsEntityAutocompleteFieldz` attribute. - -```php -use Symfony\Component\Security\Core\Security; -use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField; -use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType; - -#[AsEntityAutocompleteField] -class FoodAutocompleteField extends AbstractType -{ - public function configureOptions(OptionsResolver $resolver) - { - $resolver->setDefaults([ - 'class' => Food::class, - 'placeholder' => 'What should we eat?', - - // choose which fields to use in the search - // if not passed, *all* fields are used - //'searchable_fields' => ['name'], - - // if the autocomplete endpoint needs to be secured - //'security' => 'ROLE_FOOD_ADMIN', - - // ... any other normal EntityType options - // e.g. query_builder, choice_label - ]); - } - - public function getParent(): string - { - return BaseEntityAutocompleteType::class; - } -} -``` diff --git a/docs/development/fixtures.md b/docs/development/fixtures.md deleted file mode 100644 index da4665df..00000000 --- a/docs/development/fixtures.md +++ /dev/null @@ -1,4 +0,0 @@ -# Using fixtures - -Fixtures in the framework are handled by -the [DoctrineFixturesBundle](http://symfony.com/doc/current/bundles/DoctrineFixturesBundle/index.html). diff --git a/docs/development/migrations.md b/docs/development/migrations.md deleted file mode 100644 index 8705202a..00000000 --- a/docs/development/migrations.md +++ /dev/null @@ -1,24 +0,0 @@ -# Changing the database - -While developing you sometimes need to change the database. But to be able to -change the database on the server while deploying instead of doing these -changes manually we will use migrations. - -Migrations in the framework are handled by -the [DoctrineMigrationsBundle](http://symfony.com/doc/current/bundles/DoctrineMigrationsBundle/index.html). - -## Usage - -Basically it is simple. The only thing you have to do when you need to change -the database structure is to change the annotation/configuration/... and run: - - bin/console doctrine:migrations:diff - -This will generate a migration-class which you can commit along your changes. -Of course you will need to run the migration to update your own database: - - bin/console doctrine:migration:migrate - -This is only the basic usage but there are more options in -the [official doctrine documentation]((http://symfony.com/doc/current/bundles/DoctrineMigrationsBundle/index.html)). -It is possible to execute migrations up and down or create custom migration files. diff --git a/docs/development/pdf.md b/docs/development/pdf.md deleted file mode 100644 index ac3c8f12..00000000 --- a/docs/development/pdf.md +++ /dev/null @@ -1,3 +0,0 @@ -# Creating PDFs - -You can render pdfs with [dreadnip/chrome-pdf-bundle](https://github.com/sanderdlm/chrome-pdf-bundle). \ No newline at end of file diff --git a/docs/development/encrypted.md b/docs/encrypted.md similarity index 100% rename from docs/development/encrypted.md rename to docs/encrypted.md diff --git a/docs/development/forms.md b/docs/forms.md similarity index 100% rename from docs/development/forms.md rename to docs/forms.md diff --git a/docs/frontend/frontend-development.md b/docs/frontend-development.md similarity index 100% rename from docs/frontend/frontend-development.md rename to docs/frontend-development.md diff --git a/docs/frontend/components.html b/docs/frontend/components.html deleted file mode 100644 index 0ec2ab52..00000000 --- a/docs/frontend/components.html +++ /dev/null @@ -1,234 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-

Lorem ipsum

-
-
-
-

Lorem ipsum dolor sit amet

- - -
-
...
-
...
-
...
-
...
-
- - - - - - - - - - -
......
......
-
-
- - - - - - - - -
- - - - - - - - - - - - -
...
...
-
- - - -
- - {{ 'filter.empty.users'|trans }} -
- - - -Primary - - - - - -
- - {{ 'quotation.empty.items'|trans }} -
- - -
- - -
-
-
-
Card title
-

- Card body text -

-
- -
-
- - -
-
-
-
{{ dossier.id }}
- {% if dossier.trader and dossier.trader.traderContact %} -

- {{ dossier.trader.traderContact.firstName }} {{ dossier.trader.traderContact.lastName }} -

- {% endif %} -
- -
-
- - -
-
- -
-

Card title

-
-
-
-
-
{{ 'Location'|trans }}:
-
AB | Brussel
-
-
-
{{ 'Dates'|trans }}:
-
18/08
-
-
-
{{ 'Genre'|trans }}:
-
Rock
-
-
- -
-
-
diff --git a/docs/frontend/mails.md b/docs/frontend/mails.md deleted file mode 100644 index 8ac6cde0..00000000 --- a/docs/frontend/mails.md +++ /dev/null @@ -1,11 +0,0 @@ -# Mail stijling - -We use Foundation, so consult the [docs](https://get.foundation/emails/docs/global.html) to see -what your options are regarding components, helpers, ... - -## Logo - -We use a logo with fixed width (182px) that is set on the img-tag in the mail template. This is because some -mail clients can't handle the size of an image right. We also use `.gif` extension for the same reason. - -You can change the width of an image, but than you have to update the width attribute on the img-tag too. diff --git a/docs/frontend/installation.md b/docs/installation.md similarity index 100% rename from docs/frontend/installation.md rename to docs/installation.md diff --git a/docs/development/language-switch.md b/docs/language-switch.md similarity index 100% rename from docs/development/language-switch.md rename to docs/language-switch.md diff --git a/docs/development/mails.md b/docs/mails.md similarity index 76% rename from docs/development/mails.md rename to docs/mails.md index 54239ca0..f0c1c299 100644 --- a/docs/development/mails.md +++ b/docs/mails.md @@ -32,6 +32,15 @@ template.html.twig {% endblock %} ``` +## Template styling + +Mail templates use [Foundation for Emails](https://get.foundation/emails/docs/global.html) for responsive table-based layouts. Consult the Foundation docs for available components and helpers. + +### Logo + +Use a logo with fixed width (182px) set directly on the `img` tag. Some mail clients cannot handle CSS-based image sizing. Use `.gif` extension for the broadest compatibility. + +When you change the logo width, update the `width` attribute on the `img` tag to match. ```php Date: Thu, 4 Jun 2026 17:02:26 +0200 Subject: [PATCH 2/9] docs: add index.md as entry point with architecture overview Includes subsystem table, request lifecycle diagram, key injectable services, PHP attributes reference, and pointers to external packages that were previously documented in removed stub files. Co-Authored-By: Claude Sonnet 4.6 --- docs/index.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/index.md diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..ce2e7886 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,84 @@ +# FrameworkCoreBundle Documentation + +`FrameworkCoreBundle` is a Symfony bundle providing shared scaffolding for SumoCoders projects: page titles, breadcrumbs, menus, forms, file/image uploads, audit logging, pagination, and a frontend design system. + +Install via the [application-skeleton](https://github.com/sumocoders/application-skeleton). PHP ^8.5, Symfony ^8.0, Doctrine ^3.3. + +--- + +## Subsystems + +| Doc | What it does | +|-----|-------------| +| [breadcrumb.md](breadcrumb.md) | `#[Breadcrumb]` attribute — builds breadcrumb trails from controller annotations | +| [title.md](title.md) | `#[Title]` attribute — sets the page `` and `<h1>` | +| [audit-trail.md](audit-trail.md) | `#[AuditTrail]` attribute — logs entity creates/updates/deletes | +| [pagination.md](pagination.md) | `Paginator` — wraps a Doctrine QueryBuilder for paginated results | +| [menu.md](menu.md) | `MenuBuilder` + `ConfigureMenuEvent` — KnpMenu-based navigation | +| [forms.md](forms.md) | Custom form types (`ImageType`, `FileType`, `BelgiumPostCodeType`) and type extensions | +| [uploading-files.md](uploading-files.md) | `AbstractFile` + DBAL type — file upload value objects wired to Doctrine | +| [uploading-images.md](uploading-images.md) | `AbstractImage` + DBAL type — image upload value objects with fallback support | +| [encrypted.md](encrypted.md) | `EncryptedDBALType` — transparent field-level encryption via libsodium | +| [mails.md](mails.md) | Bundle email base template and async dispatch pattern | +| [using-date-pickers.md](using-date-pickers.md) | Date/time picker form type extensions | +| [button-locations.md](button-locations.md) | Toolbar and form submit button placement conventions | +| [language-switch.md](language-switch.md) | Multi-locale navigation switcher | +| [installation.md](installation.md) | Frontend asset installation | +| [frontend-development.md](frontend-development.md) | SCSS variables, dark mode, JS components | +| [ajax-client.md](ajax-client.md) | Axios-based AJAX client with CSRF and toast support | +| [asset-mapper.md](asset-mapper.md) | Adding CSS/JS packages via Symfony AssetMapper | +| [dark-mode.md](dark-mode.md) | Dark mode support and how to disable it | +| [stimulus.md](stimulus.md) | Stimulus controllers provided by the bundle | +| [no-results.md](no-results.md) | Standard empty-state / no-results UI component | + +--- + +## Architecture + +### Request lifecycle + +``` +HTTP request + └─ kernel.controller_arguments (priority -1) + ├─ BreadcrumbListener — reads #[Breadcrumb] from class + method, populates BreadcrumbTrail + └─ TitleListener — reads #[Title] from method, writes PageTitle + Falls back to BreadcrumbTrail if no #[Title] present +``` + +### Key injectable services + +| Class | Purpose | Inject as | +|-------|---------|-----------| +| `SumoCoders\FrameworkCoreBundle\Service\BreadcrumbTrail` | Current request breadcrumbs (iterable) | `BreadcrumbTrail $breadcrumbTrail` | +| `SumoCoders\FrameworkCoreBundle\Service\PageTitle` | Computed page title string | `PageTitle $pageTitle` | +| `SumoCoders\FrameworkCoreBundle\Service\Fallbacks` | Global config fallbacks (site title etc.) | `Fallbacks $fallbacks` | +| `SumoCoders\FrameworkCoreBundle\Menu\MenuBuilder` | KnpMenu factory; dispatches `ConfigureMenuEvent` | `MenuBuilder $menuBuilder` | +| `SumoCoders\FrameworkCoreBundle\Pagination\Paginator` | Paginates a `QueryBuilder` | constructor injection | +| `SumoCoders\FrameworkCoreBundle\Logger\AuditLogger` | Writes audit log entries | `AuditLogger $auditLogger` | + +### PHP attributes + +| Attribute | Target | What it does | +|-----------|--------|-------------| +| `#[Breadcrumb]` | method / class | Adds one crumb to the trail; repeatable; supports `parent:` chaining | +| `#[Title]` | method | Explicit page title with `{param}` interpolation | +| `#[AuditTrail]` | entity class | Enables Doctrine audit logging | +| `#[SensitiveData]` | entity property | Masks value in audit log as `*****` | + +### Service configuration + +All services are registered in `config/services.php` using PHP-format DI config. Autowiring and autoconfiguration are enabled. `Configuration.php` is intentionally empty — no runtime bundle config is needed. + +--- + +## External packages (not documented here) + +These packages are commonly used alongside this bundle. Consult their own documentation: + +| Package | Docs | +|---------|------| +| `symfony/ux-autocomplete` | [Symfony UX Autocomplete](https://symfony.com/bundles/ux-autocomplete/current/index.html) | +| `doctrine/doctrine-fixtures-bundle` | [DoctrineFixturesBundle](https://symfony.com/bundles/DoctrineFixturesBundle/current/index.html) | +| `doctrine/doctrine-migrations-bundle` | [DoctrineMigrationsBundle](https://symfony.com/bundles/DoctrineMigrationsBundle/current/index.html) | +| `dreadnip/chrome-pdf-bundle` | [chrome-pdf-bundle on GitHub](https://github.com/sanderdlm/chrome-pdf-bundle) | +| Deployer | Deployment config is application-specific — see your project's `deploy.php` | From 68626d7b7aa6b2f6a8bd7178ec95841b6567cf94 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen <tijs@verkoyen.eu> Date: Thu, 4 Jun 2026 17:04:29 +0200 Subject: [PATCH 3/9] docs: rewrite stub docs (encrypted, dark-mode, installation, no-results) All four were 6-15 lines with no actionable detail. Now each follows the standard format: purpose, prerequisites, usage, options, examples, troubleshooting. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- docs/dark-mode.md | 34 ++++++++++++++--- docs/encrypted.md | 90 ++++++++++++++++++++++++++++++++++++++++---- docs/installation.md | 41 +++++++++++++++++--- docs/no-results.md | 30 +++++++++++++-- 4 files changed, 173 insertions(+), 22 deletions(-) diff --git a/docs/dark-mode.md b/docs/dark-mode.md index 2888a105..2444f481 100644 --- a/docs/dark-mode.md +++ b/docs/dark-mode.md @@ -1,11 +1,35 @@ # Dark mode -We use bootstrap color themes for dark mode styling. See the [documentation](https://getbootstrap.com/docs/5.3/customize/color-modes/) for more information. -Color variables are defined in `assets/scss/_bootstrap-variables-dark.scss`. +The bundle uses Bootstrap 5.3 color themes for dark mode. The active theme is stored in a cookie (`theme`) and applied via the `data-bs-theme` attribute on `<html>`. The Twig function `theme()` reads the cookie and returns the correct class. + +Color variables are defined in `assets/scss/_bootstrap-variables-dark.scss`. See the [Bootstrap color modes documentation](https://getbootstrap.com/docs/5.3/customize/color-modes/) for all available CSS variables. + +## How it works + +1. `FrameworkExtension` (Twig) provides `theme()` — returns `'theme-light'` or `'theme-{cookieValue}'` +2. A Stimulus controller (`dark-mode`) toggles the cookie and updates `data-bs-theme` on `<html>` without a page reload +3. `templates/partials/themetoggler.html.twig` renders the toggle button + +## Customize variables + +Override Bootstrap dark-mode CSS variables in `assets/scss/_bootstrap-variables-dark.scss`: + +```scss +// assets/scss/_bootstrap-variables-dark.scss +[data-bs-theme="dark"] { + --bs-body-bg: #1a1a2e; + --bs-body-color: #e0e0e0; +} +``` ## Disable dark mode -Set `$enable-dark-mode` to false in `assets/scss/_bootstrap-variables.scss` to disable dark mode completely. +1. Set `$enable-dark-mode: false` in `assets/scss/_bootstrap-variables.scss` +2. Remove or comment out the dark mode logo and its `{% if %}` block in `templates/navigation.html.twig` +3. Remove `{% include 'partials/themetoggler.html.twig' %}` from your base layout + +## Troubleshooting -- Remove dark mode logo and if statements on the light mode logo in `templates/navigation.html.twig` -- Hide or remove themetoggler.html.twig from your base layout +- **Toggle button not visible** — check that `themetoggler.html.twig` is included in your base template +- **Theme resets on page reload** — the Stimulus controller writes a `theme` cookie; verify cookies are not blocked and the domain matches +- **Dark variables not applying** — confirm `_bootstrap-variables-dark.scss` is imported after the Bootstrap variables file in your main SCSS entry point diff --git a/docs/encrypted.md b/docs/encrypted.md index 2201ed55..0629efce 100644 --- a/docs/encrypted.md +++ b/docs/encrypted.md @@ -1,14 +1,88 @@ -# Encrypted strings in the database +# Encrypted fields -The core bundle has a DBAL type which encrypts strings with the built-in PHP libsodium functions. To use it, simply -apply the type to a string in your entity. +Transparently encrypts and decrypts a Doctrine column using libsodium (`sodium_crypto_secretbox`). The value is stored as `TEXT` in the database; PHP reads and writes a plain string. Encryption is field-level — the rest of the entity is not affected. -Example: +## Prerequisites + +- PHP with the `sodium` extension (bundled since PHP 7.2) +- `ENCRYPTION_KEY` set in `.env.local` — a 64-character hex string (32 bytes) + +Generate a key: + +```bash +php -r "echo sodium_bin2hex(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES)) . PHP_EOL;" +``` + +Add to `.env.local`: + +```dotenv +ENCRYPTION_KEY=your_64_char_hex_string_here +``` + +## Usage + +Register the DBAL type in `config/packages/doctrine.yaml`: + +```yaml +doctrine: + dbal: + types: + encrypted: SumoCoders\FrameworkCoreBundle\DBALType\EncryptedDBALType +``` + +Use the `encrypted` type on any string property: ```php - /** - * @ORM\Column(type="encrypted") - */ - private string $encryptedString; +<?php + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class User +{ + #[ORM\Column(type: 'encrypted', nullable: true)] + private ?string $socialSecurityNumber = null; + + public function getSocialSecurityNumber(): ?string + { + return $this->socialSecurityNumber; + } + + public function setSocialSecurityNumber(?string $value): void + { + $this->socialSecurityNumber = $value; + } +} ``` +## How it works + +| Direction | Operation | +|-----------|-----------| +| Write (PHP → DB) | `sodium_crypto_secretbox` encrypts the value; stored as `hex(nonce)|hex(ciphertext)` | +| Read (DB → PHP) | Splits on `|`, decrypts with `sodium_crypto_secretbox_open`, returns plain string | + +The nonce is randomly generated per write, so the same plaintext produces a different ciphertext each time. + +## Limitations + +- **Not searchable** — encrypted values cannot be used in `WHERE` clauses or indexes. Filter in PHP after fetching. +- **Type is always `TEXT`** — column length constraints have no effect. +- **String only** — the type stores and returns a string. Cast integers, dates, etc. in your entity getter/setter. +- **No key rotation built in** — changing `ENCRYPTION_KEY` requires re-encrypting all rows manually. + +## Migrations + +When adding an encrypted column to an existing table with data: + +1. Add the nullable `encrypted` column +2. In a separate migration, read and re-save each row through the entity manager so the DBAL type encrypts the values +3. Apply any `NOT NULL` constraint in a third migration after the backfill + +## Troubleshooting + +- **`RuntimeException: ENCRYPTION_KEY should be a valid 64 character key`** — the env var is missing or not loaded. Check `.env.local` and restart the dev server. +- **`ConversionException` on read** — the stored value was encrypted with a different key, or the column contains a plain-text legacy value. Decrypt/migrate those rows before switching keys. +- **Column shows `(Encrypted)` comment in the database** — expected; the DBAL type sets that as the column comment automatically. diff --git a/docs/installation.md b/docs/installation.md index 5752a42b..5968a5f1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,6 +1,37 @@ -# Installation +# Frontend installation -1. `git clone <git-repo>` -2. `symfony composer install` -4. `npm install` -5. `npm run build` +Install the frontend assets after cloning a project that uses this bundle. + +## Prerequisites + +- Node.js 20+ and npm 10+ +- PHP 8.5+ with Symfony CLI + +## Steps + +```bash +git clone <git-repo> +symfony composer install +npm install +npm run build +``` + +## Development + +Start the dev server with file watching: + +```bash +npm run watch +``` + +Or using Webpack Encore hot module replacement: + +```bash +npm run dev-server +``` + +## Troubleshooting + +- **`npm install` fails** — ensure you are on Node 20+: `node --version` +- **Assets not updating** — run `php bin/console cache:clear` after changing SCSS variables +- **Missing `@sumocoders/framework-style-package`** — the npm package must be listed in `package.json`; run `npm install` again diff --git a/docs/no-results.md b/docs/no-results.md index f4840306..a094cdb5 100644 --- a/docs/no-results.md +++ b/docs/no-results.md @@ -1,10 +1,32 @@ -# No results in datagrid / no data on page +# No-results state -Use the following 'no results' snippet when there are no results or data in a (filtered) datagrid or on a page. +Use this component when a list, datagrid, or page has no data to display — including after filtering produces zero results. -``` +## Usage + +```twig <div class="data-no-results"> <img src="{{ asset('images/no-results.svg') }}" alt=""> - {{ 'quotation.empty.items'|trans }} (replace with correct translation) + {{ 'your.translation.key'|trans }} </div> ``` + +Replace `'your.translation.key'` with a translation key appropriate to the context (e.g. `'users.empty'`, `'orders.no_results'`). + +## With a filter hint + +When the empty state is caused by an active filter, add a reset link so the user can clear it: + +```twig +{% if filtersActive %} + <div class="data-no-results"> + <img src="{{ asset('images/no-results.svg') }}" alt=""> + {{ 'your.translation.key'|trans }} + <a href="{{ path(app.request.attributes.get('_route')) }}">{{ 'general.reset_filters'|trans }}</a> + </div> +{% endif %} +``` + +## Accessibility + +The `<img>` carries an empty `alt=""` because it is decorative. The text content must be meaningful on its own. From ff6489e8f46d863d004a9f7512cc403284b31868 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen <tijs@verkoyen.eu> Date: Thu, 4 Jun 2026 17:07:16 +0200 Subject: [PATCH 4/9] docs: fix and expand incomplete docs - using-date-pickers: fix cut-off code block, add full options table and troubleshooting - forms: document ImageType, FileType, BelgiumPostCodeType + all type extensions (CollectionType, PasswordType, date/time pickers) with full option tables - menu: fix typo in example (extra quote), add icon usage, active-state patterns, dropdown example, troubleshooting - pagination: fix session key inconsistency (vegetation_filter -> user_filter), add full Paginator API reference table, sorting example, troubleshooting - mails: already merged from frontend/mails.md in commit 1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- docs/forms.md | 192 +++++++++++++++++++++++++++++----- docs/menu.md | 135 +++++++++++++----------- docs/pagination.md | 209 +++++++++++++++++++++---------------- docs/using-date-pickers.md | 97 +++++++++-------- 4 files changed, 409 insertions(+), 224 deletions(-) diff --git a/docs/forms.md b/docs/forms.md index 972325db..acedbee5 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -1,46 +1,186 @@ # Forms -## Translations +The bundle provides custom form types for images, files, and Belgium postcodes, plus type extensions that enhance the built-in Symfony form types. + +--- + +## Custom form types + +### ImageType -The label for a field will be automatically translated. +Renders a file input for an `AbstractImage` subclass with an optional preview and a remove checkbox. The form field expects the entity property to be typed as your `AbstractImage` subclass. + +See [uploading-images.md](uploading-images.md) for creating the required `AbstractImage` subclass. ```php - ... - ->add( - 'username', - TextType::class, - ) - ... +<?php + +use SumoCoders\FrameworkCoreBundle\Form\Type\ImageType; + +$builder->add('photo', ImageType::class, [ + 'image_class' => UserPhoto::class, + 'label' => 'forms.labels.photo', + 'help' => 'forms.help.photo', + 'accept' => 'image/jpeg,image/png,image/webp', + 'show_preview' => true, + 'show_remove_image' => true, + 'remove_image_label' => 'forms.labels.removeImage', + 'required_image_error' => 'forms.not_blank', +]); ``` -Will result in +**Options:** -```html +| Option | Type | Default | Required | Description | +|--------|------|---------|----------|-------------| +| `image_class` | `string` | — | yes | FQCN of your `AbstractImage` subclass | +| `label` | `string` | — | yes | Form field label | +| `help` | `string` | — | yes | Help text below the field | +| `accept` | `string` | `'image/*'` | yes | Accepted MIME types for the file input | +| `show_preview` | `bool` | `true` | yes | Show current image as a preview | +| `show_remove_image` | `bool` | `true` | yes | Show a checkbox to delete the current image | +| `remove_image_label` | `string` | `'forms.labels.removeImage'` | yes | Label for the remove checkbox | +| `required_image_error` | `string` | `'forms.not_blank'` | yes | Validation message when a required image is missing | -<div class="form-group"> - <label for="xxx_form_username" class="form-label"> - Username<abbr title="this field is required">*</abbr> - </label> - <input type="text" id="xxx_form_username" name="xxx_form[username]" required="required" class="form-control"> -</div> -``` -Where `Username` is a translation. So if you want to translate the label, you can do it like this: +--- -```yaml -Username: 'Enter your username' +### FileType + +Same as `ImageType` but for `AbstractFile` subclasses (any file type). + +See [uploading-files.md](uploading-files.md) for creating the required `AbstractFile` subclass. + +```php +<?php + +use SumoCoders\FrameworkCoreBundle\Form\Type\FileType; + +$builder->add('document', FileType::class, [ + 'file_class' => UserDocument::class, + 'label' => 'forms.labels.document', + 'help' => 'forms.help.document', + 'accept' => 'application/pdf', + 'show_preview' => true, + 'preview_label' => 'forms.labels.viewCurrentFile', + 'show_remove_file' => true, + 'remove_file_label' => 'forms.labels.removeFile', + 'required_file_error' => 'forms.not_blank', +]); ``` -## Belgium postcode +**Options:** + +| Option | Type | Default | Required | Description | +|--------|------|---------|----------|-------------| +| `file_class` | `string` | — | yes | FQCN of your `AbstractFile` subclass | +| `label` | `string` | — | yes | Form field label | +| `help` | `string` | — | yes | Help text below the field | +| `accept` | `string\|null` | `null` | yes | Accepted MIME types for the file input (`null` = any) | +| `show_preview` | `bool` | `true` | yes | Show a link to the current file | +| `preview_label` | `string` | `'forms.labels.viewCurrentFile'` | yes | Link text for the current file preview | +| `show_remove_file` | `bool` | `true` | yes | Show a checkbox to delete the current file | +| `remove_file_label` | `string` | `'forms.labels.removeFile'` | yes | Label for the remove checkbox | +| `required_file_error` | `string` | `'forms.not_blank'` | yes | Validation message when a required file is missing | + +--- -Form type that allows only valid belgian postcodes to be selected. +### BelgiumPostCodeType + +A select field restricted to valid Belgian postcodes. Internally uses a predefined list of all Belgian postcodes. -Use in your own form: ```php - $builder->add('postcode', BelgiumPostCodeType::class); +<?php + +use SumoCoders\FrameworkCoreBundle\Form\Type\BelgiumPostCodeType; +use SumoCoders\FrameworkCoreBundle\ValueObject\BelgiumPostCode; +use Symfony\Component\Validator\Constraints\NotBlank; + +$builder->add('postcode', BelgiumPostCodeType::class); ``` -Add a property in the DTO: +Add a `BelgiumPostCode` property to your DTO: + ```php - #[Assert\NotBlank] +<?php + +use SumoCoders\FrameworkCoreBundle\ValueObject\BelgiumPostCode; +use Symfony\Component\Validator\Constraints\NotBlank; + +class AddressData +{ + #[NotBlank] public ?BelgiumPostCode $postcode = null; +} ``` + +--- + +## Type extensions + +These extensions apply automatically to the listed Symfony form types — no explicit registration needed. + +### Date, Time, DateTime, Birthday + +Activates [Flatpickr](https://flatpickr.js.org/) on `DateType`, `TimeType`, `DateTimeType`, and `BirthdayType`. The `widget` defaults to `single_text` and `html5` defaults to `false`. + +See [using-date-pickers.md](using-date-pickers.md) for options and examples. + +### CollectionType + +Adds drag-and-drop reordering and minimum/maximum item count validation to `CollectionType`. + +**Added options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `allow_drag_and_drop` | `bool` | `true` | Enable drag-and-drop row reordering | +| `add_button_label` | `string` | `'forms.buttons.addItem'` | Translation key for the "Add item" button | +| `minimum_required_items` | `int` | `0` | Minimum number of items required | +| `maximum_required_items` | `int\|null` | `null` | Maximum number of items allowed (`null` = unlimited) | + +```php +$builder->add('contacts', CollectionType::class, [ + 'entry_type' => ContactType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'minimum_required_items' => 1, + 'maximum_required_items' => 5, +]); +``` + +### PasswordType + +Adds a show/hide toggle button to `PasswordType` fields. + +**Added options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `toggle` | `bool` | `true` | Enable the show/hide toggle | +| `hidden_label` | `string\|null` | `'Hide password'` | Tooltip/label when password is visible | +| `visible_label` | `string\|null` | `'Show password'` | Tooltip/label when password is hidden | +| `button_classes` | `string[]` | `['toggle-password-button']` | CSS classes on the toggle button | +| `toggle_container_classes` | `string[]` | `['toggle-password-container']` | CSS classes on the wrapper element | + +To disable the toggle on a specific field: + +```php +$builder->add('apiKey', PasswordType::class, [ + 'toggle' => false, +]); +``` + +--- + +## Translations + +Form field labels are translated automatically using the form's translation domain. The label key is derived from the field name (e.g. field `username` → key `Username`). + +To provide a custom translation, add a key to your translation file: + +```yaml +# translations/messages.en.yaml +Username: 'Enter your username' +``` + +No change to the form builder is needed. diff --git a/docs/menu.md b/docs/menu.md index a981fe4f..f7d68c28 100644 --- a/docs/menu.md +++ b/docs/menu.md @@ -1,44 +1,14 @@ -# Adding items into the menu/navigation +# Menu -To create a menu & add items to it, you'll need to set up an event listener that listens to the -`framework_core.configure_menu` event. +The bundle builds navigation using [KnpMenu](https://symfony.com/bundles/KnpMenuBundle/current/index.html). `MenuBuilder` dispatches a `ConfigureMenuEvent` — consuming apps listen to this event to add items. -In short, you'll need to add the following: +## Prerequisites -* In src/EventListener, create a file called MenuListener just like the example below. -* In config/services.yaml, add the following configuration snippet: +`knplabs/knp-menu-bundle` must be installed (included in the application skeleton). -```yml -services: - App\EventListener\MenuListener: - tags: - - { name: kernel.event_listener, event: framework_core.configure_menu, method: onConfigureMenu } -``` - -To make things easier, there's a DefaultMenuListener to extend your MenuListener from. This base class already has three -autowired arguments: - -* TranslatorInterface -* Security -* RequestStack - -You can use them like so: - -* `$this->getTranslator()->trans('some text')` to translate stuff -* `$this->getSecurity()->isGranted('ROLE_ADMIN');` to check for roles -* `$this->getRequestStack()->getCurrentRequest()->...` to access the current request. - -There is also a helper called `enableChildRoutes`, which takes a prefix string as an argument. Calling this method on a -menu item, will activate it when a route is visited that starts with the prefix you pass. - -In short, if you have a menu item with `user_admin_overview` as the route, and you enable child routes with the `user_admin_` -prefix, all the following routes will also mark the user menu item as active: - -* `user_admin_add` -* `user_admin_edit` -* `user_admin_whatever` +## Usage -## The example listener +Create an event listener in `src/EventListener/`: ```php <?php @@ -55,14 +25,14 @@ class MenuListener extends DefaultMenuListener implements EventSubscriberInterfa { $factory = $event->getFactory(); $menu = $event->getMenu(); - - if ($this->getSecurity()->isGranted("ROLE_ADMIN")) { + + if ($this->getSecurity()->isGranted('ROLE_ADMIN')) { $menu->addChild( $factory->createItem( - $this->getTranslator()->trans('Users''), + $this->getTranslator()->trans('Users'), [ - 'route' => 'user_admin_overview', - 'labelAttributes' => [ + 'route' => 'user_admin_overview', + 'labelAttributes' => [ 'icon' => 'bi bi-person-fill', ], 'extras' => [ @@ -77,46 +47,89 @@ class MenuListener extends DefaultMenuListener implements EventSubscriberInterfa } } - /** - * @return array<string, mixed> - */ public static function getSubscribedEvents(): array { return [ConfigureMenuEvent::EVENT_NAME => 'onConfigureMenu']; } } +``` +Register the listener in `config/services.yaml`: + +```yaml +services: + App\EventListener\MenuListener: + tags: + - { name: kernel.event_listener, event: framework_core.configure_menu, method: onConfigureMenu } ``` -# Nested menu items +## DefaultMenuListener helpers -To create a dropdown menu with child items, create a parent item with `uri => #` instead of a route, and call addChild on it before adding it to the menu. +Extending `DefaultMenuListener` gives you three autowired services: -## Example nested menu item +| Method | Returns | Purpose | +|--------|---------|---------| +| `$this->getTranslator()` | `TranslatorInterface` | Translate menu item labels | +| `$this->getSecurity()` | `Security` | Check roles/permissions | +| `$this->getRequestStack()` | `RequestStack` | Access current request | + +## Active state for child routes + +`enableChildRoutes($prefix)` marks a menu item as active when the current route starts with `$prefix`: ```php -$paymentsMenuItem = $factory->createItem( +$usersItem = $factory->createItem('Users', ['route' => 'user_admin_overview']); +$this->enableChildRoutes($usersItem, 'user_admin_'); +$menu->addChild($usersItem); +``` + +All routes starting with `user_admin_` (e.g. `user_admin_add`, `user_admin_edit`) will mark the item as active. + +Alternatively, list specific routes in `extras.routes`: + +```php +'extras' => [ + 'routes' => ['user_admin_add', 'user_admin_edit'], +], +``` + +## Icons + +The bundle supports Bootstrap Icons (`bi bi-*`) and Font Awesome (`fa-* fa-*`) in `labelAttributes.icon`: + +```php +'labelAttributes' => ['icon' => 'bi bi-house-fill'], // Bootstrap Icons +'labelAttributes' => ['icon' => 'fa-solid fa-house'], // Font Awesome +``` + +## Nested items (dropdown) + +Create a parent item with `uri => '#'` and add children to it before adding to the root menu: + +```php +$paymentsItem = $factory->createItem( $this->getTranslator()->trans('Payments'), [ - 'uri' => '#', - 'labelAttributes' => [ - 'icon' => 'fa-regular fa-credit-card', - ], - ], + 'uri' => '#', + 'labelAttributes' => ['icon' => 'fa-regular fa-credit-card'], + ] ); -$paymentsMenuItem->addChild( +$paymentsItem->addChild( $factory->createItem( $this->getTranslator()->trans('Overview'), [ - 'route' => 'payments_overview', - 'labelAttributes' => [ - 'icon' => 'fa-solid fa-money-bill', - ], - ], + 'route' => 'payments_overview', + 'labelAttributes' => ['icon' => 'fa-solid fa-money-bill'], + ] ) ); -$menu->addChild($paymentsMenuItem); - +$menu->addChild($paymentsItem); ``` + +## Troubleshooting + +- **Menu item not highlighted** — add the route to `extras.routes` or use `enableChildRoutes` with the correct prefix +- **Item visible to wrong roles** — `isGranted` checks happen at render time; wrap the `addChild` call in a role check +- **Menu not rendering** — verify the listener is registered and tagged with `framework_core.configure_menu` diff --git a/docs/pagination.md b/docs/pagination.md index b2ff1e2c..8d49787e 100644 --- a/docs/pagination.md +++ b/docs/pagination.md @@ -1,144 +1,177 @@ -# Using pagination +# Pagination -Pagination is a nice way to handle large amounts of data over multiple pages. The core bundle has a Paginator class (similar to Pagerfanta), that does most of the heavy lifting. +`Paginator` wraps a Doctrine `QueryBuilder` and handles page math, result slicing, and iteration. The default page size is 30. ## Usage -Define the Paginator object in your repository, where you pass the QueryBuilder object straight to it. + ### Repository +Return a `Paginator` from the repository method. Do not call `paginate()` here — the controller does that. + ```php - use SumoCoders\FrameworkCoreBundle\Pagination\Paginator; - - public function getPaginatedItems(): Paginator +<?php + +namespace App\Repository; + +use App\Entity\Item; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\Persistence\ManagerRegistry; +use SumoCoders\FrameworkCoreBundle\Pagination\Paginator; + +class ItemRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Item::class); + } + + public function getPaginated(): Paginator { $queryBuilder = $this->createQueryBuilder('i') - ->where('i.name LIKE :term') - ->setParameter('term', 'foo') - ->orderBy('i.name'); + ->orderBy('i.name', 'ASC'); return new Paginator($queryBuilder); } +} ``` -## Controller -In your controller, use the `paginate` method on it to set the correct page. You can also extend this with sorting GET parameters that you pass to your method in the repository. Since the pagination works on a QueryBuilder object, al sorting must be done with orderBy's. +### Controller + +Call `paginate()` with the current page number from the query string: ```php <?php -namespace SumoCoders\FrameworkCoreBundle\Controller; +namespace App\Controller\Item; use App\Repository\ItemRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Routing\Attribute\Route; -final class ItemController extends AbstractController +#[Route('/items', name: 'item_index')] +final class Index extends AbstractController { - /** - * @Route("/items", name="item_index") - */ - public function __invoke( - Request $request, - ItemRepository $itemRepository - ): Response { - $paginatedItems = $itemRepository->getPaginatedItems(); - - $paginatedItems->paginate($request->query->getInt('page', 1)); - - return $this->render('items/index.html.twig', [ - 'items' => $paginatedItems, + public function __invoke(Request $request, ItemRepository $itemRepository): Response + { + $items = $itemRepository->getPaginated() + ->paginate($request->query->getInt('page', 1)); + + return $this->render('item/index.html.twig', [ + 'items' => $items, ]); } } ``` -### Filters +### Template -In a lot of projects, you'll have to integrate the pagination with some sort of filter/search. +The `Paginator` is iterable and countable. Use the `pagination()` Twig function to render the pager widget: -To do so, create a form, render it on the overview page where you have the pagination, and pass the form values to your repository where you can use them in your querybuilder. +```twig +{% if items|length > 0 %} + {% for item in items %} + <div>{{ item.name }}</div> + {% endfor %} +{% else %} + {% include 'partials/no-results.html.twig' %} +{% endif %} + +{% if items.hasToPaginate %} + <div class="d-flex justify-content-center"> + {{ pagination(items) }} + </div> +{% endif %} +``` + +## `Paginator` API reference + +| Method | Return type | Description | +|--------|-------------|-------------| +| `paginate(int $page = 1)` | `self` | Executes the query for the given page; returns `$this` | +| `getCurrentPage()` | `int` | Current page number | +| `getLastPage()` | `int` | Last page number (= total pages) | +| `getPageSize()` | `int` | Items per page (default 30) | +| `hasPreviousPage()` | `bool` | Whether a previous page exists | +| `getPreviousPage()` | `int` | Previous page number (minimum 1) | +| `hasNextPage()` | `bool` | Whether a next page exists | +| `getNextPage()` | `int` | Next page number (maximum last page) | +| `hasToPaginate()` | `bool` | Whether there is more than one page | +| `getNumResults()` | `int` | Total number of results across all pages | +| `getResults()` | `Traversable` | Results for the current page | +| `calculateStartAndEndPage()` | `void` | Populates `startPage`/`endPage` (±3 around current page) for pager UI | +| `getStartPage()` | `int` | First page number in the pager window (after `calculateStartAndEndPage()`) | +| `getEndPage()` | `int` | Last page number in the pager window (after `calculateStartAndEndPage()`) | + +Custom page size: -In your controller, this would look something like: ```php -$form = $this->createForm( - FilterType::class, - new FilterDataTransferObject() -); +return new Paginator($queryBuilder, pageSize: 10); +``` -$form->handleRequest($request); +## Sorting -$paginatedUsers = $userRepository->getAllFilteredUsers($form->getData()); +Add an `orderBy` to the query builder and pass the sort direction from the request: -$paginatedUsers->paginate($request->query->getInt('page', 1)); -``` -with the following matching method in the repository: ```php -public function getAllFilteredUsers(FilterDataTransferObject $filter): Paginator +public function getPaginated(string $sortField = 'name', string $sortDirection = 'ASC'): Paginator { - $queryBuilder = $this->createQueryBuilder('u'); - - if (isset($filter->term) && $filter->term !== null) { - $queryBuilder - ->where('u.email LIKE :term') - ->setParameter('term', '%' . $filter->term . '%'); + $allowedFields = ['name', 'email', 'createdAt']; + if (!in_array($sortField, $allowedFields, true)) { + $sortField = 'name'; } + $queryBuilder = $this->createQueryBuilder('u') + ->orderBy('u.' . $sortField, $sortDirection === 'DESC' ? 'DESC' : 'ASC'); + return new Paginator($queryBuilder); } ``` -Note that this approach will **not perists across page requests**, since the form will POST again (with empty values) when you move to the next page. If we want to keep the result set, we'll have to provide the entered data back to the form. The easiest way to do this is to use the session. + +Controller: + ```php -// If a filter is active, use it -if ($request->getSession()->has('user_filter')) { - $userFilterFormData = unserialize($request->getSession()->get('user_filter')); -} else { -// If not, create a blank one - $userFilterFormData = new FilterDataTransferObject(); -} +$users = $userRepository->getPaginated( + $request->query->get('sort', 'name'), + $request->query->get('direction', 'ASC'), +)->paginate($request->query->getInt('page', 1)); +``` + +## Filters with session persistence -$form = $this->createForm( - FilterType::class, - $userFilterFormData -); +Without session storage, the filter resets when the user navigates to page 2. Store filter data in the session to persist it across page requests. +```php +<?php + +use App\Form\UserFilterType; +use App\Form\UserFilterData; + +$filterData = $request->getSession()->has('user_filter') + ? unserialize($request->getSession()->get('user_filter')) + : new UserFilterData(); + +$form = $this->createForm(UserFilterType::class, $filterData); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { -// If a filter form is submitted, store the values in the session - $request->getSession()->set('vegetation_filter', serialize($form->getData())); + $filterData = $form->getData(); + $request->getSession()->set('user_filter', serialize($filterData)); } -/* - * The user can clear the filter by submitting the form with a blank value - * or you could provide a way for him to "clear the filter", where you - * remove the session variable - */ -$paginatedUsers = $userRepository->getAllFilteredUsers($form->getData()); - -$paginatedUsers->paginate($request->query->getInt('page', 1)); +$users = $userRepository->getFiltered($filterData) + ->paginate($request->query->getInt('page', 1)); ``` +To reset the filter, remove the session key: -## Template - -In your template, you have access to a Twig extension called `pagination` to render a clean pagination widget. - -The paginated object, in this case `items` is an iterator, so you can count it/loop over it to get the results of the query. +```php +$request->getSession()->remove('user_filter'); +``` -```twig -{% if items|length > 0 %} - {% for item in items %} - <ul> - <li>{{ item.id }}</li> - </ul> - {% endfor %} -{% endif %} +## Troubleshooting -{% if items.hasToPaginate %} - <div class="d-flex justify-content-center"> - {{ pagination(items) }} - </div> -{% endif %} -``` +- **Total count is wrong with JOINs** — the paginator sets `HINT_DISTINCT => false` when no JOINs are present. With JOINs, ensure your query does not produce duplicate root entities +- **`paginate()` not called** — always call `paginate()` before passing the paginator to the template; calling only the constructor does not execute the query +- **Page parameter missing** — use `$request->query->getInt('page', 1)` so an absent `?page=` defaults to page 1 diff --git a/docs/using-date-pickers.md b/docs/using-date-pickers.md index f8e2daf1..26660db6 100644 --- a/docs/using-date-pickers.md +++ b/docs/using-date-pickers.md @@ -1,68 +1,67 @@ -# Using date pickers +# Date and time pickers -While the native date and time pickers have improved over the years, we still had some edge cases to cover, so we -chose [Flatpickr](https://flatpickr.js.org/) to provide a beautiful, no-dependency datetime picker in the framework. +The bundle uses [Flatpickr](https://flatpickr.js.org/) for date and time inputs. Flatpickr activates automatically on any field using these Symfony form types — no extra configuration required: -You don't have to pass any option or attribute, the picker will always show when you use any of the following types: +- `DateType` +- `TimeType` +- `DateTimeType` +- `BirthdayType` -* DateType -* TimeType -* DateTimeType -* BirthdayType +## Prerequisites + +The `widget` option must be `single_text` (the bundle default). Do not set `html5: true`. + +## Usage ```php <?php -$builder - ->add( - 'date', - DateType::class, - [ - 'data' => new DateTime(), - 'minimum_date' => (new \DateTimeImmutable('last week'))->format('d/m/Y'), - 'maximum_date' => (new \DateTimeImmutable('next week'))->format('d/m/Y'), - ] - ); -``` +use Symfony\Component\Form\Extension\Core\Type\DateType; -For the picker to work, the `widget` option has to be set to `single_text`. This is the default, so as long as you leave -it, you should be fine. +$builder->add('date', DateType::class, [ + 'data' => new \DateTime(), +]); +``` ## Options -There are two option helpers to set date ranges: - -* `minimum_date`: takes a formatted string. Will be set as the `min` value on the Flatpickr instance. -* `maximum_date`: takes a formatted string. Will be set as the `max` value on the Flatpickr instance. +The bundle adds two convenience options to the date/time types: -All other options you'll have to set yourself. You can find the full list of options in -the [Flatpickr documentation](https://flatpickr.js.org/options/). +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `minimum_date` | `string\|null` | `null` | Earliest selectable date, formatted as `d/m/Y` | +| `maximum_date` | `string\|null` | `null` | Latest selectable date, formatted as `d/m/Y` | +| `format` | `string` | `'dd/MM/yyyy'` | Display format (Flatpickr format string) | -The easiest way to pass options is to set them as data attributes on the form field. To do this, you'll have to -transform the option name from camel to snake case. +```php +$builder->add('date', DateType::class, [ + 'data' => new \DateTime(), + 'minimum_date' => (new \DateTimeImmutable('last week'))->format('d/m/Y'), + 'maximum_date' => (new \DateTimeImmutable('next week'))->format('d/m/Y'), +]); +``` -Example: +## Passing additional Flatpickr options -* `minDate` becomes `min-date` -* `showMonths` becomes `show-months` +Pass any [Flatpickr option](https://flatpickr.js.org/options/) as a `data-date-*` attribute, converting camelCase to kebab-case: -All Flatpickr data atributes must be prefixed with `date`. A full, working example would be: +- `minDate` → `data-date-min-date` +- `showMonths` → `data-date-show-months` ```php -<?php +$builder->add('date', DateType::class, [ + 'data' => new \DateTime(), + 'minimum_date' => (new \DateTimeImmutable('last week'))->format('d/m/Y'), + 'maximum_date' => (new \DateTimeImmutable('next week'))->format('d/m/Y'), + 'attr' => [ + 'data-date-min-date' => '01/01/1993', + 'data-date-show-months' => 2, + ], +]); +``` + +## Troubleshooting -$builder - ->add( - 'date', - DateType::class, - [ - 'data' => new DateTime(), - 'minimum_date' => (new \DateTimeImmutable('last week'))->format('d/m/Y'), - 'maximum_date' => (new \DateTimeImmutable('next week'))->format('d/m/Y'), - 'attr' [ - 'data-date-min-date' => '01/01/1993', - 'data-date-show-months' => false, - ] - ] - ); -``` \ No newline at end of file +- **Picker does not open** — verify `widget` is not set to `choice` and `html5` is not `true` +- **Date format mismatch on submit** — the `format` option controls the display format; the form submits the underlying ISO value regardless of display format +- **`minimum_date`/`maximum_date` ignored** — these options must be a string in `d/m/Y` format matching the `format` option From 1bff0de8838319afd50b53c5624c4de1391d8854 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen <tijs@verkoyen.eu> Date: Thu, 4 Jun 2026 17:13:28 +0200 Subject: [PATCH 5/9] docs: apply consistent format to remaining docs Each doc now has: one-line purpose, prerequisites, options/reference table, usage examples, and troubleshooting section. - audit-trail: add log format key table (C/U/D), options table, troubleshooting; fix typo AuditTrial -> AuditTrail - breadcrumb: add options table, Twig rendering section, troubleshooting - title: add options table, troubleshooting - uploading-files/images: modernize entity examples from annotations to PHP 8 attributes (#[ORM\*]); remove FOSUserBundle references; add troubleshooting - ajax-client: add prerequisites, multipart upload example, troubleshooting - button-locations: add purpose line, accessibility notes - language-switch: expand locales config, fix Twig snippet formatting, add troubleshooting - stimulus: add prerequisites, lifecycle hooks reference table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- docs/ajax-client.md | 33 +++++- docs/audit-trail.md | 77 +++++++++---- docs/breadcrumb.md | 46 +++++++- docs/button-locations.md | 10 +- docs/language-switch.md | 69 +++++++---- docs/stimulus.md | 23 +++- docs/title.md | 20 ++++ docs/uploading-files.md | 242 ++++++++++++++------------------------- docs/uploading-images.md | 242 ++++++++++++++------------------------- 9 files changed, 396 insertions(+), 366 deletions(-) diff --git a/docs/ajax-client.md b/docs/ajax-client.md index 55127661..d7adab9f 100644 --- a/docs/ajax-client.md +++ b/docs/ajax-client.md @@ -1,6 +1,16 @@ # AJAX client -The AJAX client is a simple wrapper around [`Axios`](https://axios-http.com/). +A pre-configured [Axios](https://axios-http.com/) wrapper with CSRF support, toast notifications, and busy-button spinners. Provided by the bundle's JavaScript assets. + +## Prerequisites + +JavaScript assets must be installed: see [installation.md](installation.md). + +Import the client in your Stimulus controller: + +```javascript +import ajaxClient from '../js/ajax_client.js' +``` ## Default Axios Config @@ -138,3 +148,24 @@ ajaxClient.busy_targets = [buttonNode] ajaxClient.post(this.urlValue, data) ... ``` + +## File upload (multipart) + +For file uploads, use `FormData` and set `Content-Type` to `multipart/form-data`: + +```javascript +const formData = new FormData() +formData.append('file', fileInput.files[0]) + +ajaxClient.post(this.urlValue, formData, { + headers: { 'Content-Type': 'multipart/form-data' } +}).then(response => { + // handle response +}) +``` + +## Troubleshooting + +- **CSRF token invalid** — ensure the token id passed to `isCsrfTokenValid()` on the server matches the id used to generate the token in Twig +- **Toast not showing** — verify the response JSON contains a `message` key; without it, no toast is triggered +- **Request times out immediately** — the default timeout is 2500ms; override it per request: `ajaxClient.get(url, { timeout: 10000 })` diff --git a/docs/audit-trail.md b/docs/audit-trail.md index 34a692e0..3d51e688 100644 --- a/docs/audit-trail.md +++ b/docs/audit-trail.md @@ -1,14 +1,14 @@ # Audit trail -## Introduction +Logs entity creates, updates, and deletes to the `audit_trail` Monolog channel. Disabled by default — entities opt in with `#[AuditTrail]`. -The audit trail is a feature that allows you to track changes to your data. -It is useful for tracking changes to sensitive data, such as user accounts, or for tracking changes to data that is -important for compliance, such as financial records. +## Prerequisites + +No extra configuration required. The `DoctrineAuditListener` is registered automatically. To capture logs, configure a `audit_trail` channel in `config/packages/monolog.yaml`. ## Usage -The audit trail is NOT enabled by default. You will need to add the `#[AuditTrial]` attribute to the entity you want to track. +Add `#[AuditTrail]` to any entity class. The attribute is NOT added by default. ```php #[ORM\Entity] @@ -43,7 +43,28 @@ By default the following data is tracked: * The fields that were changed * The data that was changed -You can specify which fields you want to track by adding the specifying the `field` property in the `#[AuditTrail]` attribute. +## `#[AuditTrail]` options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `fields` | `array` | `[]` | Limit tracking to these field names. Empty array = all fields | +| `withData` | `bool` | `true` | Include old/new values in the log. Set to `false` to log field names only | + +## Log format + +``` +[datetime] audit_trail.INFO: Source: <url>; Entity: <FQCN>; Identifier: <id>; Action: <action>; User: <email>; Roles: <roles>; IP: <ip>; Fields: [<fields>]; Data: <data> +``` + +**Action codes:** + +| Code | Meaning | +|------|---------| +| `C` | Create (entity persisted for the first time) | +| `U` | Update (entity modified) | +| `D` | Delete (entity removed) | + +## Filter to specific fields ```php #[ORM\Entity] @@ -113,26 +134,38 @@ class User [2024-09-06T09:48:53.540500+00:00] audit_trail.INFO: Source: https://test.wip/profile; Entity: App\Entity\User; Identifier: 2; Action: U; User: test@sumocoders.be; Roles: ROLE_ADMIN, ROLE_USER; IP: 127.0.0.1; Fields: ["password"]; Data: []} [] [] ``` -### Manually tracking changes +## Manually tracking actions -You can manually track changes by using the `AuditLogger` service. +Inject `AuditLogger` to log non-Doctrine actions (e.g. exports, logins, API calls): ```php -class TestController extends AbstractController +<?php + +namespace App\Controller; + +use SumoCoders\FrameworkCoreBundle\Logger\AuditLogger; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +#[Route('/export', name: 'data_export')] +final class ExportController extends AbstractController { - #[Route('/test', name: 'test')] - public function __invoke( - AuditLogger $auditLogger, - ): ResponseAlias { - - $auditLogger->log( - data: ['test' => 'test'], - ); - - return $this->render( - 'test/index.html.twig', - [] - ); + public function __invoke(AuditLogger $auditLogger): Response + { + $auditLogger->log(data: ['format' => 'csv', 'rows' => 1500]); + + return $this->file('export.csv'); } } ``` + +## Performance note + +The listener fires on every Doctrine `onFlush` event. For entities that change very frequently, use `fields:` to narrow which changes are logged, or set `withData: false` to skip the field diff computation. + +## Troubleshooting + +- **No log entries appearing** — verify the `audit_trail` Monolog channel is configured and the log file/handler is writable +- **All fields tracked instead of specific ones** — `fields:` must list the exact property names as defined on the entity, not the column names +- **Sensitive data visible** — add `#[SensitiveData]` to the property; the value will be masked as `*****` in both old and new values diff --git a/docs/breadcrumb.md b/docs/breadcrumb.md index a7c778ee..811500cf 100644 --- a/docs/breadcrumb.md +++ b/docs/breadcrumb.md @@ -1,4 +1,41 @@ -# Using the breadcrumb +# Breadcrumbs + +Populates a `BreadcrumbTrail` service from `#[Breadcrumb]` attributes on controller classes and methods. The trail is available for rendering in Twig on every request. + +## Prerequisites + +No additional configuration required. `BreadcrumbListener` fires automatically on `kernel.controller_arguments`. + +## `#[Breadcrumb]` options + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `title` | `string` | required | Crumb label. Supports `{object.property}` interpolation. Passed through the translator | +| `route` | `array\|null` | `null` | Makes the crumb a link. Keys: `name` (required), `parameters` (optional array) | +| `parent` | `array\|null` | `null` | Prepends the full trail of another route. Keys: `name` (required), `parameters` (optional array) | +| `parameters` | `array` | `[]` | Translation parameters. Values are `object.property` paths resolved from controller arguments | + +The attribute targets both **methods** and **classes**, and is **repeatable** — multiple `#[Breadcrumb]` on the same element are added in declaration order. + +## Rendering in Twig + +```twig +{% for crumb in breadcrumbTrail %} + {% if loop.last %} + <li class="breadcrumb-item active">{{ crumb.title|trans }}</li> + {% else %} + <li class="breadcrumb-item"> + {% if crumb.hasRoute %} + <a href="{{ path(crumb.route.name, crumb.route.parameters ?? {}) }}">{{ crumb.title|trans }}</a> + {% else %} + {{ crumb.title|trans }} + {% endif %} + </li> + {% endif %} +{% endfor %} +``` + +`breadcrumbTrail` is available automatically in all templates via the bundle's Twig extension. ## Basics @@ -169,3 +206,10 @@ public function __invoke(Author $author, Book $book): Response ```yaml breadcrumb.authors: 'Authors' ``` + +## Troubleshooting + +- **Breadcrumb not appearing** — verify the controller method has `#[Breadcrumb]` (not the class alone, unless it is an invokable controller) +- **`{object.property}` shows literally** — scalars (e.g. `string $name`) cannot be interpolated; only object arguments with accessible properties work +- **Parent chain stops early** — every route in the chain must have its own `#[Breadcrumb]` attribute; missing one breaks the recursive resolution +- **Translation key not found** — breadcrumb titles are translated using the default domain; add the key to `translations/messages.<locale>.yaml` diff --git a/docs/button-locations.md b/docs/button-locations.md index fcaffd78..16b0384e 100644 --- a/docs/button-locations.md +++ b/docs/button-locations.md @@ -1,5 +1,7 @@ # Button locations +The bundle layout has two toolbar regions: one below the header (for secondary actions) and a fixed bottom bar (for primary actions). Use the correct region to maintain consistent UX across the app. + ## Toolbar below header Buttons in the toolbar below the header are used for secondary actions that users infrequently need to access. @@ -47,4 +49,10 @@ When using a form, you can place the submit button in the fixed toolbar by addin {% endblock %} ``` -The important part is `form="{{ form.vars.id }}"` which links the button to the form. +The important part is `form="{{ form.vars.id }}"` which links the button to the form even though it sits outside the `<form>` element. + +## Accessibility + +- Use `type="submit"` for submit buttons and `type="button"` for actions that do not submit a form — missing `type` defaults to submit, which can trigger unintended form submissions +- Destructive actions (delete) belong in `header_actions_left` so users do not accidentally activate them when reaching for the save button +- Include visible text or an `aria-label` on icon-only buttons diff --git a/docs/language-switch.md b/docs/language-switch.md index d24fc5e3..5f3070f1 100644 --- a/docs/language-switch.md +++ b/docs/language-switch.md @@ -1,32 +1,55 @@ -# Adding a new language +# Language switch -- Extend the locales parameter in config/services.yaml +The bundle supports multi-locale navigation. The active locale is part of the route URL via the `{_locale}` parameter. A dropdown in the navigation lets users switch language. -# Language switch +## Prerequisites + +Routing must be configured with `{_locale}` as a route parameter or prefix in `config/routes.yaml`. -You can add a language switch to navigation.html.twig by using the following snippet: +## Adding a new locale +Extend the `locales` parameter in `config/services.yaml`: + +```yaml +parameters: + locales: ['nl', 'fr', 'en'] ``` + +The `locales` parameter is passed to Twig and used in the language switcher dropdown. + +## Language switch snippet + +Add the following to `templates/navigation.html.twig`, between the logo and the user menu: + +```twig <div class="navbar-header d-md-none d-flex align-items-center"> - <div class="dropdown btn-group d-flex flex-column me-3"> - <a class="dropdown-toggle d-flex align-items-center" href="#" id="dropdown-language" role="button" - data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> - {{ app.request.locale|upper }} - <span class="bi bi-chevron-down"></span> - </a> - <ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language"> - {% for locale in locales %} - <li> - <a class="dropdown-item {{ locale == app.request.locale ? 'active' : '' }}" href="{{ path(app.request.attributes.get('_route'), app.request.attributes.get('_route_params')|merge({"_locale": locale})) }}">{{ locale != '' ? locale|upper : 'NL' }}</a> - </li> - {% endfor %} - </ul> - </div> - <button class="navbar-toggler navbar-toggler-right ms-auto collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-collapse-1" aria-controls="navbar-collapse-1" aria-expanded="false" aria-label="Toggle navigation"> - <span class="navbar-toggler-icon"></span> - </button> + <div class="dropdown btn-group d-flex flex-column me-3"> + <a class="dropdown-toggle d-flex align-items-center" + href="#" + id="dropdown-language" + role="button" + data-bs-toggle="dropdown" + aria-haspopup="true" + aria-expanded="false"> + {{ app.request.locale|upper }} + <span class="bi bi-chevron-down"></span> + </a> + <ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language"> + {% for locale in locales %} + <li> + <a class="dropdown-item {{ locale == app.request.locale ? 'active' : '' }}" + href="{{ path(app.request.attributes.get('_route'), app.request.attributes.get('_route_params')|merge({'_locale': locale})) }}"> + {{ locale|upper }} + </a> + </li> + {% endfor %} + </ul> + </div> </div> - ``` -Place it between the logo and user menu dropdown. +## Troubleshooting + +- **Locale not changing on click** — verify your routes include `{_locale}` as a parameter or prefix; without it, `_locale` in `_route_params` has no effect +- **`locales` variable undefined in Twig** — ensure the `locales` parameter is defined in `config/services.yaml` under `parameters:` +- **Wrong locale active after switch** — check that the Symfony locale listener is active (`framework.translator.enabled_locales` in `config/packages/translation.yaml`) diff --git a/docs/stimulus.md b/docs/stimulus.md index 39754456..a0292a2e 100644 --- a/docs/stimulus.md +++ b/docs/stimulus.md @@ -1,10 +1,23 @@ -# Stimulus +# Stimulus controllers -For our javascript needs we use Symfony UX with Stimulus. See the Symfony site for -information https://symfony.com/bundles/StimulusBundle/current/index.html or the stimulus documentation for more -information: https://stimulus.hotwired.dev/handbook/introduction +The bundle provides pre-built Stimulus controllers for common UI patterns. These are registered automatically when the frontend assets are installed. -We have a few default components that can be used in your project: +## Prerequisites + +Assets installed: see [installation.md](installation.md). Import the bundle's controllers in your `assets/controllers.json` or `assets/bootstrap.js`. + +## Lifecycle hooks + +Every Stimulus controller supports: + +| Method | When it runs | +|--------|-------------| +| `connect()` | When the controller's element is connected to the DOM | +| `disconnect()` | When the element is removed from the DOM | + +Custom controllers should call `super.connect()` if they extend a bundle controller. + +## Bundle controllers ## Clipboard diff --git a/docs/title.md b/docs/title.md index 786a55b1..8308ce91 100644 --- a/docs/title.md +++ b/docs/title.md @@ -1,5 +1,17 @@ # Page title +Sets the page `<title>` and `<h1>` from `#[Title]` attributes or, when absent, from the breadcrumb trail. Available in all Twig templates as the `pageTitle` variable. + +## `#[Title]` options + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `title` | `string` | required | Page title. Supports `{param}` (scalar) and `{object.property}` (object) interpolation. Passed through the translator | +| `parent` | `array\|null` | `null` | Prepends the title of another route. Keys: `name` (required), `parameters` (optional array) | +| `extend` | `bool` | `true` | When `true`, appends ` - <site_title>`. When `false`, uses the string verbatim | + +The attribute targets **methods only** and is **not repeatable**. + ## Resolution order The `PageTitle` service resolves the title in this order: @@ -129,4 +141,12 @@ In Twig, `PageTitle` is available as a string (via `__toString`): ```twig <title>{{ pageTitle }} +

{{ pageTitle }}

``` + +## Troubleshooting + +- **`{param}` not resolving** — the placeholder must match the exact name of a controller argument. For objects, use `{object.property}` not `{object}` +- **Title missing site name** — verify `fallbacks.site_title` is set in `parameters` in `config/services.yaml` +- **Parent chain not working** — each route in the chain must exist and have `#[Title]` or `#[Breadcrumb]` attributes; the chain resolves by dispatching a subrequest to fetch the parent's title +- **`extend: false` still appends site title** — check that `extend:` is passed as a named argument: `#[Title('My Title', extend: false)]` diff --git a/docs/uploading-files.md b/docs/uploading-files.md index e4af906c..74864f0b 100644 --- a/docs/uploading-files.md +++ b/docs/uploading-files.md @@ -1,233 +1,163 @@ # Uploading files -You can find a base value object that you can use to upload files. +The bundle provides `AbstractFile` — a value object that handles file storage, naming, and lifecycle hooks for Doctrine entities. Used with `FileType` for forms and a custom DBAL type for database persistence. -It can be used in combination with the form type `SumoCoders\FrameworkCoreBundle\Form\Type\FileType` +## Prerequisites -While most of the things you need to do are already written for you, you will still need to add some configuration for -each implementation. +- A writable `public/files/` directory in your project +- The entity must have `#[ORM\HasLifecycleCallbacks]` -## Basic implementation +## Step 1: Create the value object -### Create a value object - -Not all files are created equal. The file you want to upload has a specific meaning in your application and therefor -your implementation should reflect that. - -* Create a new class -* Extend the class `SumoCoders\FrameworkCoreBundle\ValueObject\AbstractFile` -* Implement the `getUploadDir()` method (for documentation about this see the phpdoc) - -After implementing this your value object will be transformed into the web path of your file when it is sent to the -template. - -This way you can just use it like `myEntity.myFile` - -#### Example +Create a class that extends `AbstractFile` and implements `getUploadDir()`. The upload directory is relative to `public/files/`. ```php `. -#### Example +## Step 2: Create the DBAL type ```php cv; + return $this->document; } - /** - * @param CV $cv - * @return self - */ - public function setCv($cv) + public function setDocument(?UserDocument $document): void { - $this->cv = $cv; - - return $this; + $this->document = $document; } - /** - * @ORM\PreUpdate() - * @ORM\PrePersist() - */ - public function prepareToUploadCV() + #[ORM\PrePersist] + #[ORM\PreUpdate] + public function prepareDocument(): void { - $this->cv->prepareToUpload(); + $this->document?->prepareToUpload(); } - /** - * @ORM\PostUpdate() - * @ORM\PostPersist() - */ - public function uploadCV() + #[ORM\PostPersist] + #[ORM\PostUpdate] + public function uploadDocument(): void { - $this->cv->upload(); + $this->document?->upload(); } - /** - * @ORM\PostRemove() - */ - public function removeCV() + #[ORM\PostRemove] + public function removeDocument(): void { - $this->cv->remove(); + $this->document?->remove(); } } ``` -### Update your form type - -For the last step you need to add your file to your form. - -* Use `SumoCoders\FrameworkCoreBundle\Form\Type\FileType` as the form type -* Set the fully qualified class name (FQCN) of your value object in the option `file_class` (tip: you can use - `MyFile::class` for that) - -#### Example +## Step 4: Add to form ```php add('document', FileType::class, [ + 'file_class' => UserDocument::class, + 'label' => 'forms.labels.document', + 'help' => 'forms.help.document', + 'accept' => 'application/pdf', + 'show_preview' => true, + 'preview_label' => 'forms.labels.viewCurrentFile', + 'show_remove_file' => true, + 'remove_file_label' => 'forms.labels.removeFile', + 'required_file_error' => 'forms.not_blank', +]); +``` - /** - * {@inheritdoc} - */ - public function buildForm(FormBuilderInterface $builder, array $options) - { - parent::buildForm($builder, $options); +See [forms.md](forms.md) for the full `FileType` options reference. - $builder->add('cv', FileType::class, ['file_class' => CV::class]); - } -} +## Template + +```twig +{% if user.document %} + Download document +{% endif %} ``` -## Extra configuration options +`AbstractFile` implements `__toString()` returning the web path (`/files/user/documents/`), or an empty string if no file exists. + +## `AbstractFile` API + +| Method | Description | +|--------|-------------| +| `getFileName()` | Raw stored filename | +| `getWebPath()` | Public URL path, or empty string if file missing | +| `getAbsolutePath()` | Absolute filesystem path | +| `hasFile()` | Whether a new `UploadedFile` is pending | +| `markForDeletion()` | Schedules the file for removal on next flush | +| `setNamePrefix(string)` | Prepends a slug to the generated filename | -To make your life even easier, the form FileType has some interesting configuration options on top of the default -options that the Symfony FileType already has. +## Troubleshooting -* `show_preview`: By default we will show a link to view the current file if there is one. You can disable this using - this option. -* `preview_label`: You can use it to change the translation label that will be in the link to view your current file. -* `show_remove_file`: If your file is not required we will automatically add the option for the user to remove the file, - You can disable this using this option. -* `remove_file_label`: You can use it to change the translation label of the remove file checkbox. +- **File not uploaded after form submit** — verify the three lifecycle methods (`prepareToUpload`, `upload`, `remove`) are present on the entity with the correct `#[ORM\*]` attributes +- **`public/files/` directory missing** — create it and ensure it is web-accessible; check your web server configuration +- **Old file not deleted on replace** — the `upload()` method deletes the old file; this only works if `prepareToUpload()` was called first in `PreUpdate` +- **Form always shows file as required** — `FileType` uses `required` based on whether the value object has an existing file; pass `'required' => false` to disable the constraint diff --git a/docs/uploading-images.md b/docs/uploading-images.md index 0ab2c0c0..07a18206 100644 --- a/docs/uploading-images.md +++ b/docs/uploading-images.md @@ -1,237 +1,165 @@ # Uploading images -You can find a base value object that you can use to upload images. +The bundle provides `AbstractImage` — extends `AbstractFile` with image-specific features: fallback images and web path resolution. Used with `ImageType` for forms and a custom DBAL type for database persistence. -It can be used in combination with the form type `SumoCoders\FrameworkCoreBundle\Form\Type\ImageType` +## Prerequisites -While most of the things you need to do are already written for you, you will still need to add some configuration for -each implementation. +- A writable `public/files/` directory in your project +- The entity must have `#[ORM\HasLifecycleCallbacks]` -## Basic implementation +## Step 1: Create the value object -### Create a value object - -Not all images are created equal. The image you want to upload has a specific meaning in your application and therefor -your implementation should reflect that. - -* Create a new class -* Extend the class `SumoCoders\FrameworkCoreBundle\ValueObject\AbstractImage` -* Implement the `getUploadDir()` method (for documentation about this see the phpdoc) -* If you want a fallback image you can overwrite the constant `FALLBACK_IMAGE` - -After implementing this your value object will be transformed into the web path of your file when it is sent to the -template. - -This way you can just use it like `myEntity.myImage` - -#### Example +Create a class that extends `AbstractImage` and implements `getUploadDir()`. Optionally override `FALLBACK_IMAGE` to return a web path for when no image is set. ```php avatar; } - /** - * @param Avatar $avatar - * @return self - */ - public function setAvatar($avatar) + public function setAvatar(?UserAvatar $avatar): void { $this->avatar = $avatar; - - return $this; } - /** - * @ORM\PreUpdate() - * @ORM\PrePersist() - */ - public function prepareToUploadAvatar() + #[ORM\PrePersist] + #[ORM\PreUpdate] + public function prepareAvatar(): void { - $this->avatar->prepareToUpload(); + $this->avatar?->prepareToUpload(); } - /** - * @ORM\PostUpdate() - * @ORM\PostPersist() - */ - public function uploadAvatar() + #[ORM\PostPersist] + #[ORM\PostUpdate] + public function uploadAvatar(): void { - $this->avatar->upload(); + $this->avatar?->upload(); } - /** - * @ORM\PostRemove() - */ - public function removeAvatar() + #[ORM\PostRemove] + public function removeAvatar(): void { - $this->avatar->remove(); + $this->avatar?->remove(); } } ``` -### Update your form type - -For the last step you need to add your file to your form. - -* Use `SumoCoders\FrameworkCoreBundle\Form\Type\ImageType` as the form type -* Set the fully qualified class name (FQCN) of your value object in the option `image_class` (tip: you can use - `MyImage::class` for that) - -#### Example +## Step 4: Add to form ```php add('avatar', ImageType::class, [ + 'image_class' => UserAvatar::class, + 'label' => 'forms.labels.avatar', + 'help' => 'forms.help.avatar', + 'accept' => 'image/jpeg,image/png,image/webp', + 'show_preview' => true, + 'show_remove_image' => true, + 'remove_image_label' => 'forms.labels.removeImage', + 'required_image_error' => 'forms.not_blank', +]); +``` - /** - * {@inheritdoc} - */ - public function buildForm(FormBuilderInterface $builder, array $options) - { - parent::buildForm($builder, $options); +See [forms.md](forms.md) for the full `ImageType` options reference. - $builder->add('avatar', ImageType::class, ['image_class' => Avatar::class]); - } -} +## Template + +```twig +{{ user.name }} ``` -## Extra configuration options +`getWebPath()` returns the uploaded image URL or `FALLBACK_IMAGE` if no image has been uploaded. Returns an empty string if no fallback is defined and no file exists. + +## `AbstractImage` API + +Inherits all `AbstractFile` methods plus: + +| Method | Description | +|--------|-------------| +| `getWebPath()` | Public image URL, or `FALLBACK_IMAGE` if file missing | +| `getFallbackImage()` | Returns the value of `FALLBACK_IMAGE` constant | + +## Notes + +- The bundle does not resize or optimize images. Handle resizing in the entity lifecycle callbacks or a post-upload event if needed. +- EXIF data is not stripped. For user-uploaded images consider stripping EXIF in the lifecycle callback before calling `upload()`. -To make your life even easier, the form FileType has some interesting configuration options on top of the default -options that the Symfony FileType already has. +## Troubleshooting -* `show_preview`: By default we will show a preview of the current image if there is one. You can disable this using - this option. -* `preview_class`: You can use this option to add an extra class to the preview image, for example you could add - `img-circle` to make the preview image round -* `show_remove_image`: If your image is not required we will automatically add the option for the user to remove the - image, You can disable this using this option. -* `remove_image_label`: You can use it to change the translation label of the remove image checkbox. +- **Image not uploaded after form submit** — verify the three lifecycle methods (`prepareToUpload`, `upload`, `remove`) are present on the entity +- **Fallback image not showing** — `FALLBACK_IMAGE` must be an absolute public path (e.g. `/images/no-avatar.png`), not a relative path +- **Old image not deleted on replace** — ensure `prepareToUpload()` is called in `PreUpdate`; it stores the old filename for deletion during `upload()` +- **Preview not showing in form** — the `ImageType` calls `getWebPath()` on the current value; check the file exists at the returned path From bb13c902b9c686abfdbd3d7e2da04951ccb96ad6 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Thu, 4 Jun 2026 17:15:01 +0200 Subject: [PATCH 6/9] docs: update CLAUDE.md and README.md to reflect flat doc structure - CLAUDE.md: update subsystem links from docs/development/* to docs/*, fix BreadcrumbListener event name (controller_arguments, not controller), update Documentation section to point to docs/index.md and describe the doc format standard - README.md: improve description to name specific subsystems, update Documentation section to point to docs/index.md Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 17 +++++++++------ 2 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d28fe8d4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## About this bundle + +`FrameworkCoreBundle` is a Symfony 8 bundle used as a shared foundation across SumoCoders projects. It is not a standalone application — it is installed into projects via `sumocoders/application-skeleton`. PHP ^8.5, Symfony ^8.0, Doctrine ^3.3. + +## Commands + +```bash +# Install dependencies +composer install + +# Run tests +./vendor/bin/phpunit + +# Run a single test file +./vendor/bin/phpunit tests/path/to/FooTest.php +``` + +No phpstan or phpcs config exists in this repo — those tools are configured per-project in the consuming application. + +## Architecture + +### Request lifecycle + +Two event listeners fire on every request: + +1. `BreadcrumbListener` (`kernel.controller_arguments`, priority -1) — reads `#[Breadcrumb]` attributes from the matched controller and populates `BreadcrumbTrail`. +2. `TitleListener` (`kernel.controller_arguments`, priority -1) — reads `#[Title]` attributes and writes to `PageTitle`. Falls back to breadcrumbs if no `#[Title]` is present. + +`PageTitle` and `BreadcrumbTrail` are request-scoped services aliased for direct injection. + +### Attribute-driven configuration + +Controller behaviour is controlled via PHP 8 attributes, not YAML or annotations: + +| Attribute | Target | Purpose | +|-----------|--------|---------| +| `#[Breadcrumb]` | method / class | Adds one crumb; repeatable for chains; supports `parent:` for automatic trail building | +| `#[Title]` | method | Explicit page title; supports `{param}` / `{object.property}` interpolation and `parent:` chaining | +| `#[AuditTrail]` | entity class | Enables Doctrine audit logging for that entity; `withData: false` skips field-level diff | +| `#[SensitiveData]` | entity property | Masks property value in audit log output | + +### Subsystems + +- **Pagination** (`src/Pagination/Paginator.php`) — wraps a Doctrine `QueryBuilder`; exposes current page, total results, prev/next. See `docs/pagination.md`. +- **Menu** (`src/Menu/MenuBuilder.php`) — KnpMenu builder dispatching `ConfigureMenuEvent`; consuming apps listen to that event to add items. See `docs/menu.md`. +- **Audit trail** (`src/DoctrineListener/DoctrineAuditListener.php`) — Doctrine `onFlush` + `postPersist` listener that logs creates/updates/deletes via `AuditLogger`. Entities opt in with `#[AuditTrail]`. See `docs/audit-trail.md`. +- **Forms** (`src/Form/`) — custom types (`ImageType`, `FileType`, `BelgiumPostCodeType`) and extensions wiring date pickers, toggle-password, and collection UI. See `docs/forms.md`. +- **DBAL types** (`src/DBALType/`) — `EncryptedDBALType`, `AbstractImageType`, `AbstractFileType` for custom column handling. See `docs/encrypted.md`, `docs/uploading-files.md`, `docs/uploading-images.md`. +- **Twig** (`src/Twig/`) — `FrameworkExtension`, `PaginatorExtension`, `ContentExtension`; `PageTitle` is available as a string in templates. +- **Doctrine extension** (`src/Extensions/Doctrine/MatchAgainst.php`) — custom DQL function for MySQL `MATCH ... AGAINST` full-text search. + +### Service registration + +All services are registered in `config/services.php` (PHP-format DI config, no YAML). The bundle extension (`src/DependencyInjection/SumoCodersFrameworkCoreExtension.php`) loads that file. `Configuration.php` is intentionally empty — no runtime bundle config is needed. + +## Documentation + +All docs live in `docs/` (flat, no subdirectories). Start at `docs/index.md` for an overview of all subsystems, the request lifecycle, and key injectable services. + +When adding or changing a subsystem, update the corresponding doc file in `docs/`. Each doc follows the format: purpose, prerequisites, usage, options table, examples, troubleshooting. diff --git a/README.md b/README.md index daaf4501..219a66b7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,20 @@ # SumoCoders FrameworkCoreBundle -This bundle is created and maintained by [SumoCoders](https://github.com/sumocoders). It contains a set of basic tools that enable us to build an application in a shorter timespan. The bundle is intended to be used together with our [npm package](https://github.com/sumocoders/FrameworkStylePackage). + +Shared foundation bundle for SumoCoders Symfony projects. Provides page titles, breadcrumbs, navigation menus, file/image uploads, encrypted fields, audit logging, pagination, and a frontend design system. Created and maintained by [SumoCoders](https://github.com/sumocoders). ## Installation -To properly use this bundle, create a new project with our application skeleton: -``` -$ composer create-project sumocoders/application-skeleton my_project + +Create a new project with the application skeleton — it installs this bundle, the npm package, and all required config: + +```bash +composer create-project sumocoders/application-skeleton my_project ``` -The skeleton will load this bundle, install our npm package and add some extra config for CI, deployment, etc.. + +Requires PHP ^8.5, Symfony ^8.0, Doctrine ^3.3. ## Documentation -All documentation is located in the `docs/` subdirectory. + +Start at [`docs/index.md`](docs/index.md) for a full subsystem overview, architecture diagram, and key class reference. ## Issues? Feel free to add an Issue on Github, or even better create a PR. From b92f3cb0d751ccc05ec79237f882f21952e634a8 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Thu, 4 Jun 2026 17:16:34 +0200 Subject: [PATCH 7/9] docs: remove stale link to deleted components.html Co-Authored-By: Claude Sonnet 4.6 --- docs/frontend-development.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/frontend-development.md b/docs/frontend-development.md index 22fa8821..481442a6 100644 --- a/docs/frontend-development.md +++ b/docs/frontend-development.md @@ -38,7 +38,7 @@ Most importantly, there are no separate dark mode files or stylesheets. Use `@in ### Custom components or extensions (Sass) Place custom SCSS components in `assets/scss/components/`. Import your components after the Bootstrap imports. Try to base your custom components on Bootstrap components as much as possible. -You can find frequently used components in the documentation file [components.html](https://github.com/sumocoders/FrameworkCoreBundle/blob/master/docs/frontend/components.html). + ### Folder overview From 4bc767f76ed736da8f0ac8ad8909fab0ef63f558 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Thu, 4 Jun 2026 17:38:13 +0200 Subject: [PATCH 8/9] chore: fix formatting --- CLAUDE.md | 60 +++++++++++++------- README.md | 16 ++++-- docs/ajax-client.md | 15 +++-- docs/asset-mapper.md | 7 ++- docs/audit-trail.md | 43 +++++++++------ docs/breadcrumb.md | 36 +++++++----- docs/button-locations.md | 14 +++-- docs/dark-mode.md | 22 +++++--- docs/encrypted.md | 35 +++++++----- docs/forms.md | 82 +++++++++++++++------------- docs/frontend-development.md | 46 ++++++++++++---- docs/index.md | 103 +++++++++++++++++++---------------- docs/installation.md | 37 ------------- docs/language-switch.md | 14 +++-- docs/mails.md | 8 ++- docs/menu.md | 25 +++++---- docs/no-results.md | 6 +- docs/pagination.md | 48 ++++++++-------- docs/stimulus.md | 15 ++--- docs/title.md | 34 +++++++----- docs/uploading-files.md | 42 ++++++++------ docs/uploading-images.md | 41 ++++++++------ docs/using-date-pickers.md | 24 ++++---- 23 files changed, 437 insertions(+), 336 deletions(-) delete mode 100644 docs/installation.md diff --git a/CLAUDE.md b/CLAUDE.md index d28fe8d4..105bfdf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## About this bundle -`FrameworkCoreBundle` is a Symfony 8 bundle used as a shared foundation across SumoCoders projects. It is not a standalone application — it is installed into projects via `sumocoders/application-skeleton`. PHP ^8.5, Symfony ^8.0, Doctrine ^3.3. +`FrameworkCoreBundle` is a Symfony 8 bundle used as a shared foundation across SumoCoders projects. It is not a +standalone application. It is installed into projects via `sumocoders/application-skeleton`. + +Requirements: + +- PHP ^8.5 +- Symfony ^8.0, +- Doctrine ^3.3. ## Commands @@ -19,7 +26,7 @@ composer install ./vendor/bin/phpunit tests/path/to/FooTest.php ``` -No phpstan or phpcs config exists in this repo — those tools are configured per-project in the consuming application. +No phpstan or phpcs config exists in this repo. Those are configured per-project in the consuming application. ## Architecture @@ -27,8 +34,10 @@ No phpstan or phpcs config exists in this repo — those tools are configured pe Two event listeners fire on every request: -1. `BreadcrumbListener` (`kernel.controller_arguments`, priority -1) — reads `#[Breadcrumb]` attributes from the matched controller and populates `BreadcrumbTrail`. -2. `TitleListener` (`kernel.controller_arguments`, priority -1) — reads `#[Title]` attributes and writes to `PageTitle`. Falls back to breadcrumbs if no `#[Title]` is present. +1. `BreadcrumbListener` (`kernel.controller_arguments`, priority -1): reads `#[Breadcrumb]` attributes from the matched + controller and populates `BreadcrumbTrail`. +2. `TitleListener` (`kernel.controller_arguments`, priority -1): reads `#[Title]` attributes and writes to `PageTitle`. + Falls back to breadcrumbs if no `#[Title]` is present. `PageTitle` and `BreadcrumbTrail` are request-scoped services aliased for direct injection. @@ -36,29 +45,40 @@ Two event listeners fire on every request: Controller behaviour is controlled via PHP 8 attributes, not YAML or annotations: -| Attribute | Target | Purpose | -|-----------|--------|---------| -| `#[Breadcrumb]` | method / class | Adds one crumb; repeatable for chains; supports `parent:` for automatic trail building | -| `#[Title]` | method | Explicit page title; supports `{param}` / `{object.property}` interpolation and `parent:` chaining | -| `#[AuditTrail]` | entity class | Enables Doctrine audit logging for that entity; `withData: false` skips field-level diff | -| `#[SensitiveData]` | entity property | Masks property value in audit log output | +| Attribute | Target | Purpose | +|--------------------|-----------------|----------------------------------------------------------------------------------------------------| +| `#[Breadcrumb]` | method / class | Adds one crumb; repeatable for chains; supports `parent:` for automatic trail building | +| `#[Title]` | method | Explicit page title; supports `{param}` / `{object.property}` interpolation and `parent:` chaining | +| `#[AuditTrail]` | entity class | Enables Doctrine audit logging for that entity; `withData: false` skips field-level diff | +| `#[SensitiveData]` | entity property | Masks property value in audit log output | ### Subsystems -- **Pagination** (`src/Pagination/Paginator.php`) — wraps a Doctrine `QueryBuilder`; exposes current page, total results, prev/next. See `docs/pagination.md`. -- **Menu** (`src/Menu/MenuBuilder.php`) — KnpMenu builder dispatching `ConfigureMenuEvent`; consuming apps listen to that event to add items. See `docs/menu.md`. -- **Audit trail** (`src/DoctrineListener/DoctrineAuditListener.php`) — Doctrine `onFlush` + `postPersist` listener that logs creates/updates/deletes via `AuditLogger`. Entities opt in with `#[AuditTrail]`. See `docs/audit-trail.md`. -- **Forms** (`src/Form/`) — custom types (`ImageType`, `FileType`, `BelgiumPostCodeType`) and extensions wiring date pickers, toggle-password, and collection UI. See `docs/forms.md`. -- **DBAL types** (`src/DBALType/`) — `EncryptedDBALType`, `AbstractImageType`, `AbstractFileType` for custom column handling. See `docs/encrypted.md`, `docs/uploading-files.md`, `docs/uploading-images.md`. -- **Twig** (`src/Twig/`) — `FrameworkExtension`, `PaginatorExtension`, `ContentExtension`; `PageTitle` is available as a string in templates. -- **Doctrine extension** (`src/Extensions/Doctrine/MatchAgainst.php`) — custom DQL function for MySQL `MATCH ... AGAINST` full-text search. +- **Pagination** (`src/Pagination/Paginator.php`): wraps a Doctrine `QueryBuilder`; exposes current page, total + results, prev/next. See `docs/pagination.md`. +- **Menu** (`src/Menu/MenuBuilder.php`): KnpMenu builder dispatching `ConfigureMenuEvent`; consuming apps listen to + that event to add items. See `docs/menu.md`. +- **Audit trail** (`src/DoctrineListener/DoctrineAuditListener.php`): Doctrine `onFlush` + `postPersist` listener that + logs creates/updates/deletes via `AuditLogger`. Entities opt in with `#[AuditTrail]`. See `docs/audit-trail.md`. +- **Forms** (`src/Form/`): custom types (`ImageType`, `FileType`, `BelgiumPostCodeType`) and extensions wiring date + pickers, toggle-password, and collection UI. See `docs/forms.md`. +- **DBAL types** (`src/DBALType/`): `EncryptedDBALType`, `AbstractImageType`, `AbstractFileType` for custom column + handling. See `docs/encrypted.md`, `docs/uploading-files.md`, `docs/uploading-images.md`. +- **Twig** (`src/Twig/`): `FrameworkExtension`, `PaginatorExtension`, `ContentExtension`; `PageTitle` is available as a + string in templates. +- **Doctrine extension** (`src/Extensions/Doctrine/MatchAgainst.php`): custom DQL function for MySQL + `MATCH ... AGAINST` full-text search. ### Service registration -All services are registered in `config/services.php` (PHP-format DI config, no YAML). The bundle extension (`src/DependencyInjection/SumoCodersFrameworkCoreExtension.php`) loads that file. `Configuration.php` is intentionally empty — no runtime bundle config is needed. +All services are registered in `config/services.php` (PHP-format DI config, no YAML). The bundle extension +(`src/DependencyInjection/SumoCodersFrameworkCoreExtension.php`) loads that file. `Configuration.php` is intentionally +empty. No runtime bundle config is needed. ## Documentation -All docs live in `docs/` (flat, no subdirectories). Start at `docs/index.md` for an overview of all subsystems, the request lifecycle, and key injectable services. +All docs live in `docs/`. Start at `docs/index.md` for an overview of all subsystems, the request lifecycle, and key +injectable services. -When adding or changing a subsystem, update the corresponding doc file in `docs/`. Each doc follows the format: purpose, prerequisites, usage, options table, examples, troubleshooting. +When adding or changing a subsystem, update the corresponding doc file in `docs/`. Each doc follows the format: purpose, +prerequisites, usage, options table, examples, troubleshooting. diff --git a/README.md b/README.md index 219a66b7..b0274d6a 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,31 @@ # SumoCoders FrameworkCoreBundle -Shared foundation bundle for SumoCoders Symfony projects. Provides page titles, breadcrumbs, navigation menus, file/image uploads, encrypted fields, audit logging, pagination, and a frontend design system. Created and maintained by [SumoCoders](https://github.com/sumocoders). +Shared foundation bundle for SumoCoders Symfony projects. Provides page titles, breadcrumbs, navigation menus, +file/image uploads, encrypted fields, audit logging, pagination, and a frontend design system. Created and maintained +by [SumoCoders](https://github.com/sumocoders). ## Installation -Create a new project with the application skeleton — it installs this bundle, the npm package, and all required config: +Create a new project with the application skeleton. It installs this bundle and all required config: ```bash composer create-project sumocoders/application-skeleton my_project ``` -Requires PHP ^8.5, Symfony ^8.0, Doctrine ^3.3. +Requires: + +- PHP ^8.5 +- Symfony ^8.0 +- Doctrine ^3.3. ## Documentation -Start at [`docs/index.md`](docs/index.md) for a full subsystem overview, architecture diagram, and key class reference. +Start at [`docs/index.md`](docs/index.md). ## Issues? + Feel free to add an Issue on Github, or even better create a PR. ## License + This software is published under the [MIT License](LICENSE.md) diff --git a/docs/ajax-client.md b/docs/ajax-client.md index d7adab9f..29b57c9a 100644 --- a/docs/ajax-client.md +++ b/docs/ajax-client.md @@ -1,6 +1,7 @@ # AJAX client -A pre-configured [Axios](https://axios-http.com/) wrapper with CSRF support, toast notifications, and busy-button spinners. Provided by the bundle's JavaScript assets. +A pre-configured [Axios](https://axios-http.com/) wrapper with CSRF support, toast notifications, and busy-button +spinners. Provided by the bundle's JavaScript assets. ## Prerequisites @@ -126,7 +127,7 @@ A simple way to "protect" the AJAX calls is by using a CSRF token. This is done ```javascript ajaxClient.csrf_token = this.csrfTokenValue ajaxClient.post(this.urlValue, data) - ... +... ``` With this the csrf token is added to the payload of the request, with the key `csrf_token`. @@ -146,7 +147,7 @@ The content of a clicked button can be replaced by a spinner during the request. ```javascript ajaxClient.busy_targets = [buttonNode] ajaxClient.post(this.urlValue, data) - ... +... ``` ## File upload (multipart) @@ -166,6 +167,8 @@ ajaxClient.post(this.urlValue, formData, { ## Troubleshooting -- **CSRF token invalid** — ensure the token id passed to `isCsrfTokenValid()` on the server matches the id used to generate the token in Twig -- **Toast not showing** — verify the response JSON contains a `message` key; without it, no toast is triggered -- **Request times out immediately** — the default timeout is 2500ms; override it per request: `ajaxClient.get(url, { timeout: 10000 })` +- **CSRF token invalid**: ensure the token id passed to `isCsrfTokenValid()` on the server matches the id used to + generate the token in Twig +- **Toast not showing**: verify the response JSON contains a `message` key; without it, no toast is triggered +- **Request times out immediately**: the default timeout is 2500ms; override it per request: + `ajaxClient.get(url, { timeout: 10000 })` diff --git a/docs/asset-mapper.md b/docs/asset-mapper.md index 5fd20ee7..c2bdb1df 100644 --- a/docs/asset-mapper.md +++ b/docs/asset-mapper.md @@ -1,6 +1,7 @@ # Asset mapper -We use Symfony asset mapper for our css and js packages. See Symfony documentation for more information: https://symfony.com/doc/current/frontend/asset_mapper.html +We use Symfony asset mapper for our css and js packages. See Symfony documentation for more +information: https://symfony.com/doc/current/frontend/asset_mapper.html ## Add package @@ -44,8 +45,8 @@ symfony console importmap:install ## Our CSS -Our main CSS is currently located in the framework-core-bundle as this still needs to be compile with scss for Bootstrap. -This CSS is included in the `assets/styles/style.scss` file. +Our main CSS is located in the framework-core-bundle as this still needs to be compile with scss for Bootstrap. This CSS +is included in the `assets/styles/style.scss` file. To compile the CSS with SASS, you can run the following command: diff --git a/docs/audit-trail.md b/docs/audit-trail.md index 3d51e688..19aa4459 100644 --- a/docs/audit-trail.md +++ b/docs/audit-trail.md @@ -1,10 +1,12 @@ # Audit trail -Logs entity creates, updates, and deletes to the `audit_trail` Monolog channel. Disabled by default — entities opt in with `#[AuditTrail]`. +Logs entity creates, updates, and deletes to the `audit_trail` Monolog channel. Disabled by default. Entities opt in +with `#[AuditTrail]`. ## Prerequisites -No extra configuration required. The `DoctrineAuditListener` is registered automatically. To capture logs, configure a `audit_trail` channel in `config/packages/monolog.yaml`. +No extra configuration required. The `DoctrineAuditListener` is registered automatically. To capture logs, configure a +`audit_trail` channel in `config/packages/monolog.yaml`. ## Usage @@ -31,7 +33,8 @@ class Book [2024-09-06T08:30:40.145881+00:00] audit_trail.INFO: Source: https://test.wip/trail; Entity: App\Entity\Book; Identifier: 1; Action: C; User: test@sumocoders.be; Roles: ROLE_ADMIN, ROLE_USER; IP: 127.0.0.1; Fields: []; Data: {"title":"The Lord of the Rings","author":"J. R. R. Tolkien","price":40.50} [] [] ``` -By default the following data is tracked: +By default, the following data is tracked: + * The date and time of the action * The source of the action (e.g. the url of the request, the command that was run, etc.) * The entity that was changed @@ -45,10 +48,10 @@ By default the following data is tracked: ## `#[AuditTrail]` options -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `fields` | `array` | `[]` | Limit tracking to these field names. Empty array = all fields | -| `withData` | `bool` | `true` | Include old/new values in the log. Set to `false` to log field names only | +| Option | Type | Default | Description | +|------------|---------|---------|---------------------------------------------------------------------------| +| `fields` | `array` | `[]` | Limit tracking to these field names. Empty array = all fields | +| `withData` | `bool` | `true` | Include old/new values in the log. Set to `false` to log field names only | ## Log format @@ -58,11 +61,11 @@ By default the following data is tracked: **Action codes:** -| Code | Meaning | -|------|---------| -| `C` | Create (entity persisted for the first time) | -| `U` | Update (entity modified) | -| `D` | Delete (entity removed) | +| Code | Meaning | +|------|----------------------------------------------| +| `C` | Create (entity persisted for the first time) | +| `U` | Update (entity modified) | +| `D` | Delete (entity removed) | ## Filter to specific fields @@ -89,6 +92,7 @@ class Book You can hide secure data from the audit trail by adding the `#[SensitiveData]` attribute to the property. This will transform the data to `****` in the audit trail. + ```php #[AuditTrail] #[ORM\Entity] @@ -111,7 +115,8 @@ class User [2024-09-06T09:48:53.540500+00:00] audit_trail.INFO: Source: https://test.wip/profile; Entity: App\Entity\User; Identifier: 2; Action: U; User: test@sumocoders.be; Roles: ROLE_ADMIN, ROLE_USER; IP: 127.0.0.1; Fields: ["password"]; Data: {"password":{"from":"*****","to":"*****"}} [] [] ``` -There is also an option to only track the fields that are changes without the data, with the option `withData` set to `false`. +There is also an option to only track the fields that are changes without the data, with the option `withData` set to +`false`. ```php #[AuditTrail(withData: false)] @@ -162,10 +167,14 @@ final class ExportController extends AbstractController ## Performance note -The listener fires on every Doctrine `onFlush` event. For entities that change very frequently, use `fields:` to narrow which changes are logged, or set `withData: false` to skip the field diff computation. +The listener fires on every Doctrine `onFlush` event. For entities that change very frequently, use `fields:` to narrow +which changes are logged, or set `withData: false` to skip the field diff computation. ## Troubleshooting -- **No log entries appearing** — verify the `audit_trail` Monolog channel is configured and the log file/handler is writable -- **All fields tracked instead of specific ones** — `fields:` must list the exact property names as defined on the entity, not the column names -- **Sensitive data visible** — add `#[SensitiveData]` to the property; the value will be masked as `*****` in both old and new values +- **No log entries appearing**: verify the `audit_trail` Monolog channel is configured and the log file/handler is + writable +- **All fields tracked instead of specific ones**: `fields:` must list the exact property names as defined on the + entity, not the column names +- **Sensitive data visible**: add `#[SensitiveData]` to the property; the value will be masked as `*****` in both old + and new values diff --git a/docs/breadcrumb.md b/docs/breadcrumb.md index 811500cf..ebf30634 100644 --- a/docs/breadcrumb.md +++ b/docs/breadcrumb.md @@ -1,6 +1,7 @@ # Breadcrumbs -Populates a `BreadcrumbTrail` service from `#[Breadcrumb]` attributes on controller classes and methods. The trail is available for rendering in Twig on every request. +Populates a `BreadcrumbTrail` service from `#[Breadcrumb]` attributes on controller classes and methods. The trail is +available for rendering in Twig on every request. ## Prerequisites @@ -8,14 +9,15 @@ No additional configuration required. `BreadcrumbListener` fires automatically o ## `#[Breadcrumb]` options -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `title` | `string` | required | Crumb label. Supports `{object.property}` interpolation. Passed through the translator | -| `route` | `array\|null` | `null` | Makes the crumb a link. Keys: `name` (required), `parameters` (optional array) | -| `parent` | `array\|null` | `null` | Prepends the full trail of another route. Keys: `name` (required), `parameters` (optional array) | -| `parameters` | `array` | `[]` | Translation parameters. Values are `object.property` paths resolved from controller arguments | +| Parameter | Type | Default | Description | +|--------------|---------------|----------|--------------------------------------------------------------------------------------------------| +| `title` | `string` | required | Crumb label. Supports `{object.property}` interpolation. Passed through the translator | +| `route` | `array\|null` | `null` | Makes the crumb a link. Keys: `name` (required), `parameters` (optional array) | +| `parent` | `array\|null` | `null` | Prepends the full trail of another route. Keys: `name` (required), `parameters` (optional array) | +| `parameters` | `array` | `[]` | Translation parameters. Values are `object.property` paths resolved from controller arguments | -The attribute targets both **methods** and **classes**, and is **repeatable** — multiple `#[Breadcrumb]` on the same element are added in declaration order. +The attribute targets both **methods** and **classes**, and is **repeatable**. Multiple `#[Breadcrumb]` on the same +element are added in declaration order. ## Rendering in Twig @@ -39,7 +41,7 @@ The attribute targets both **methods** and **classes**, and is **repeatable** ## Basics -Add a `#[Breadcrumb]` attribute to a controller method to register a crumb. The attribute is repeatable — each one +Add a `#[Breadcrumb]` attribute to a controller method to register a crumb. The attribute is repeatable. Each one appends a crumb to the trail in declaration order. ```php @@ -85,7 +87,7 @@ class BooksController ## Linked breadcrumbs (`route:`) Pass `route:` to make the crumb a link. Required route parameters are automatically resolved from the current -controller's named arguments and request attributes — you do not need to specify them manually. +controller's named arguments and request attributes. You do not need to specify them manually. ```php #[Route('/books/genres', name: 'genres_overview')] @@ -148,7 +150,7 @@ public function __invoke(Author $author, Book $book): Response } ``` -> Scalar parameters (e.g. `string $name`) cannot be used with the `{name}` syntax — only objects with a property path +> Scalar parameters (e.g. `string $name`) cannot be used with the `{name}` syntax. Only objects with a property path > are supported. Using a scalar silently omits the breadcrumb. ## Translations @@ -209,7 +211,11 @@ breadcrumb.authors: 'Authors' ## Troubleshooting -- **Breadcrumb not appearing** — verify the controller method has `#[Breadcrumb]` (not the class alone, unless it is an invokable controller) -- **`{object.property}` shows literally** — scalars (e.g. `string $name`) cannot be interpolated; only object arguments with accessible properties work -- **Parent chain stops early** — every route in the chain must have its own `#[Breadcrumb]` attribute; missing one breaks the recursive resolution -- **Translation key not found** — breadcrumb titles are translated using the default domain; add the key to `translations/messages..yaml` +- **Breadcrumb not appearing**: verify the controller method has `#[Breadcrumb]` (not the class alone, unless it is an + invokable controller) +- **`{object.property}` shows literally**: scalars (e.g. `string $name`) cannot be interpolated; only object arguments + with accessible properties work +- **Parent chain stops early**: every route in the chain must have its own `#[Breadcrumb]` attribute; missing one + breaks the recursive resolution +- **Translation key not found**: breadcrumb titles are translated using the default domain; add the key to + `translations/messages..yaml` diff --git a/docs/button-locations.md b/docs/button-locations.md index 16b0384e..b0531546 100644 --- a/docs/button-locations.md +++ b/docs/button-locations.md @@ -1,6 +1,7 @@ # Button locations -The bundle layout has two toolbar regions: one below the header (for secondary actions) and a fixed bottom bar (for primary actions). Use the correct region to maintain consistent UX across the app. +The bundle layout has two toolbar regions: one below the header (for secondary actions) and a fixed bottom bar (for +primary actions). Use the correct region to maintain consistent UX across the app. ## Toolbar below header @@ -14,10 +15,12 @@ Put them in {% block header_navigation %} ``` ## Fixed toolbar on the bottom + Buttons in the fixed toolbar on the bottom are used for primary actions that users frequently need to access. They are always visible, even when the user scrolls down the page. There are three positions: + - **Left** `{% block header_actions_left %}` -- typically used for "dangerous" actions like delete - **Center** `{% block header_actions_center %}` -- other actions that are frequently used but not primary - **Right** `{% block header_actions_right %}` -- typically used for primary actions like save or add @@ -49,10 +52,13 @@ When using a form, you can place the submit button in the fixed toolbar by addin {% endblock %} ``` -The important part is `form="{{ form.vars.id }}"` which links the button to the form even though it sits outside the `
` element. +The important part is `form="{{ form.vars.id }}"` which links the button to the form even though it sits outside the +`` element. ## Accessibility -- Use `type="submit"` for submit buttons and `type="button"` for actions that do not submit a form — missing `type` defaults to submit, which can trigger unintended form submissions -- Destructive actions (delete) belong in `header_actions_left` so users do not accidentally activate them when reaching for the save button +- Use `type="submit"` for submit buttons and `type="button"` for actions that do not submit a form. Missing `type` + defaults to submit, which can trigger unintended form submissions +- Destructive actions (delete) belong in `header_actions_left` so users do not accidentally activate them when reaching + for the save button - Include visible text or an `aria-label` on icon-only buttons diff --git a/docs/dark-mode.md b/docs/dark-mode.md index 2444f481..c8fd144c 100644 --- a/docs/dark-mode.md +++ b/docs/dark-mode.md @@ -1,12 +1,16 @@ # Dark mode -The bundle uses Bootstrap 5.3 color themes for dark mode. The active theme is stored in a cookie (`theme`) and applied via the `data-bs-theme` attribute on ``. The Twig function `theme()` reads the cookie and returns the correct class. +The bundle uses Bootstrap 5.3 color themes for dark mode. The active theme is stored in a cookie (`theme`) and applied +via the `data-bs-theme` attribute on ``. The Twig function `theme()` reads the cookie and returns the correct +class. -Color variables are defined in `assets/scss/_bootstrap-variables-dark.scss`. See the [Bootstrap color modes documentation](https://getbootstrap.com/docs/5.3/customize/color-modes/) for all available CSS variables. +Color variables are defined in `assets/scss/_bootstrap-variables-dark.scss`. See +the [Bootstrap color modes documentation](https://getbootstrap.com/docs/5.3/customize/color-modes/) for all available +CSS variables. ## How it works -1. `FrameworkExtension` (Twig) provides `theme()` — returns `'theme-light'` or `'theme-{cookieValue}'` +1. `FrameworkExtension` (Twig) provides `theme()`, returns `'theme-light'` or `'theme-{cookieValue}'` 2. A Stimulus controller (`dark-mode`) toggles the cookie and updates `data-bs-theme` on `` without a page reload 3. `templates/partials/themetoggler.html.twig` renders the toggle button @@ -17,8 +21,8 @@ Override Bootstrap dark-mode CSS variables in `assets/scss/_bootstrap-variables- ```scss // assets/scss/_bootstrap-variables-dark.scss [data-bs-theme="dark"] { - --bs-body-bg: #1a1a2e; - --bs-body-color: #e0e0e0; + --bs-body-bg: #1a1a2e; + --bs-body-color: #e0e0e0; } ``` @@ -30,6 +34,8 @@ Override Bootstrap dark-mode CSS variables in `assets/scss/_bootstrap-variables- ## Troubleshooting -- **Toggle button not visible** — check that `themetoggler.html.twig` is included in your base template -- **Theme resets on page reload** — the Stimulus controller writes a `theme` cookie; verify cookies are not blocked and the domain matches -- **Dark variables not applying** — confirm `_bootstrap-variables-dark.scss` is imported after the Bootstrap variables file in your main SCSS entry point +- **Toggle button not visible**: check that `themetoggler.html.twig` is included in your base template +- **Theme resets on page reload**: the Stimulus controller writes a `theme` cookie; verify cookies are not blocked and + the domain matches +- **Dark variables not applying**: confirm `_bootstrap-variables-dark.scss` is imported after the Bootstrap variables + file in your main SCSS entry point diff --git a/docs/encrypted.md b/docs/encrypted.md index 0629efce..2b173a5c 100644 --- a/docs/encrypted.md +++ b/docs/encrypted.md @@ -1,11 +1,13 @@ # Encrypted fields -Transparently encrypts and decrypts a Doctrine column using libsodium (`sodium_crypto_secretbox`). The value is stored as `TEXT` in the database; PHP reads and writes a plain string. Encryption is field-level — the rest of the entity is not affected. +Transparently encrypts and decrypts a Doctrine column using libsodium (`sodium_crypto_secretbox`). The value is stored +as `TEXT` in the database; PHP reads and writes a plain string. Encryption is field-level, the rest of the entity is +not affected. ## Prerequisites - PHP with the `sodium` extension (bundled since PHP 7.2) -- `ENCRYPTION_KEY` set in `.env.local` — a 64-character hex string (32 bytes) +- `ENCRYPTION_KEY` set in `.env.local`: a 64-character hex string (32 bytes) Generate a key: @@ -25,9 +27,9 @@ Register the DBAL type in `config/packages/doctrine.yaml`: ```yaml doctrine: - dbal: - types: - encrypted: SumoCoders\FrameworkCoreBundle\DBALType\EncryptedDBALType + dbal: + types: + encrypted: SumoCoders\FrameworkCoreBundle\DBALType\EncryptedDBALType ``` Use the `encrypted` type on any string property: @@ -59,19 +61,19 @@ class User ## How it works -| Direction | Operation | -|-----------|-----------| +| Direction | Operation | +|------------------|---------------------------------------------------------------------| | Write (PHP → DB) | `sodium_crypto_secretbox` encrypts the value; stored as `hex(nonce)|hex(ciphertext)` | -| Read (DB → PHP) | Splits on `|`, decrypts with `sodium_crypto_secretbox_open`, returns plain string | +| Read (DB → PHP) | Splits on `|`, decrypts with `sodium_crypto_secretbox_open`, returns plain string | The nonce is randomly generated per write, so the same plaintext produces a different ciphertext each time. ## Limitations -- **Not searchable** — encrypted values cannot be used in `WHERE` clauses or indexes. Filter in PHP after fetching. -- **Type is always `TEXT`** — column length constraints have no effect. -- **String only** — the type stores and returns a string. Cast integers, dates, etc. in your entity getter/setter. -- **No key rotation built in** — changing `ENCRYPTION_KEY` requires re-encrypting all rows manually. +- **Not searchable**: encrypted values cannot be used in `WHERE` clauses or indexes. Filter in PHP after fetching. +- **Type is always `TEXT`**: column length constraints have no effect. +- **String only**: the type stores and returns a string. Cast integers, dates, etc. in your entity getter/setter. +- **No key rotation built in**: changing `ENCRYPTION_KEY` requires re-encrypting all rows manually. ## Migrations @@ -83,6 +85,9 @@ When adding an encrypted column to an existing table with data: ## Troubleshooting -- **`RuntimeException: ENCRYPTION_KEY should be a valid 64 character key`** — the env var is missing or not loaded. Check `.env.local` and restart the dev server. -- **`ConversionException` on read** — the stored value was encrypted with a different key, or the column contains a plain-text legacy value. Decrypt/migrate those rows before switching keys. -- **Column shows `(Encrypted)` comment in the database** — expected; the DBAL type sets that as the column comment automatically. +- **`RuntimeException: ENCRYPTION_KEY should be a valid 64 character key`**: the env var is missing or not loaded. + Check `.env.local` and restart the dev server. +- **`ConversionException` on read**: the stored value was encrypted with a different key, or the column contains a + plain-text legacy value. Decrypt/migrate those rows before switching keys. +- **Column shows `(Encrypted)` comment in the database**: expected; the DBAL type sets that as the column comment + automatically. diff --git a/docs/forms.md b/docs/forms.md index acedbee5..b853f7d7 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -1,6 +1,7 @@ # Forms -The bundle provides custom form types for images, files, and Belgium postcodes, plus type extensions that enhance the built-in Symfony form types. +The bundle provides custom form types for images, files, and Belgium postcodes, plus type extensions that enhance the +built-in Symfony form types. --- @@ -8,7 +9,8 @@ The bundle provides custom form types for images, files, and Belgium postcodes, ### ImageType -Renders a file input for an `AbstractImage` subclass with an optional preview and a remove checkbox. The form field expects the entity property to be typed as your `AbstractImage` subclass. +Renders a file input for an `AbstractImage` subclass with an optional preview and a remove checkbox. The form field +expects the entity property to be typed as your `AbstractImage` subclass. See [uploading-images.md](uploading-images.md) for creating the required `AbstractImage` subclass. @@ -31,16 +33,16 @@ $builder->add('photo', ImageType::class, [ **Options:** -| Option | Type | Default | Required | Description | -|--------|------|---------|----------|-------------| -| `image_class` | `string` | — | yes | FQCN of your `AbstractImage` subclass | -| `label` | `string` | — | yes | Form field label | -| `help` | `string` | — | yes | Help text below the field | -| `accept` | `string` | `'image/*'` | yes | Accepted MIME types for the file input | -| `show_preview` | `bool` | `true` | yes | Show current image as a preview | -| `show_remove_image` | `bool` | `true` | yes | Show a checkbox to delete the current image | -| `remove_image_label` | `string` | `'forms.labels.removeImage'` | yes | Label for the remove checkbox | -| `required_image_error` | `string` | `'forms.not_blank'` | yes | Validation message when a required image is missing | +| Option | Type | Default | Required | Description | +|------------------------|----------|------------------------------|----------|-----------------------------------------------------| +| `image_class` | `string` | | yes | FQCN of your `AbstractImage` subclass | +| `label` | `string` | | yes | Form field label | +| `help` | `string` | | yes | Help text below the field | +| `accept` | `string` | `'image/*'` | yes | Accepted MIME types for the file input | +| `show_preview` | `bool` | `true` | yes | Show current image as a preview | +| `show_remove_image` | `bool` | `true` | yes | Show a checkbox to delete the current image | +| `remove_image_label` | `string` | `'forms.labels.removeImage'` | yes | Label for the remove checkbox | +| `required_image_error` | `string` | `'forms.not_blank'` | yes | Validation message when a required image is missing | --- @@ -70,17 +72,17 @@ $builder->add('document', FileType::class, [ **Options:** -| Option | Type | Default | Required | Description | -|--------|------|---------|----------|-------------| -| `file_class` | `string` | — | yes | FQCN of your `AbstractFile` subclass | -| `label` | `string` | — | yes | Form field label | -| `help` | `string` | — | yes | Help text below the field | -| `accept` | `string\|null` | `null` | yes | Accepted MIME types for the file input (`null` = any) | -| `show_preview` | `bool` | `true` | yes | Show a link to the current file | -| `preview_label` | `string` | `'forms.labels.viewCurrentFile'` | yes | Link text for the current file preview | -| `show_remove_file` | `bool` | `true` | yes | Show a checkbox to delete the current file | -| `remove_file_label` | `string` | `'forms.labels.removeFile'` | yes | Label for the remove checkbox | -| `required_file_error` | `string` | `'forms.not_blank'` | yes | Validation message when a required file is missing | +| Option | Type | Default | Required | Description | +|-----------------------|----------------|----------------------------------|----------|-------------------------------------------------------| +| `file_class` | `string` | | yes | FQCN of your `AbstractFile` subclass | +| `label` | `string` | | yes | Form field label | +| `help` | `string` | | yes | Help text below the field | +| `accept` | `string\|null` | `null` | yes | Accepted MIME types for the file input (`null` = any) | +| `show_preview` | `bool` | `true` | yes | Show a link to the current file | +| `preview_label` | `string` | `'forms.labels.viewCurrentFile'` | yes | Link text for the current file preview | +| `show_remove_file` | `bool` | `true` | yes | Show a checkbox to delete the current file | +| `remove_file_label` | `string` | `'forms.labels.removeFile'` | yes | Label for the remove checkbox | +| `required_file_error` | `string` | `'forms.not_blank'` | yes | Validation message when a required file is missing | --- @@ -117,11 +119,12 @@ class AddressData ## Type extensions -These extensions apply automatically to the listed Symfony form types — no explicit registration needed. +These extensions apply automatically to the listed Symfony form types. No explicit registration needed. ### Date, Time, DateTime, Birthday -Activates [Flatpickr](https://flatpickr.js.org/) on `DateType`, `TimeType`, `DateTimeType`, and `BirthdayType`. The `widget` defaults to `single_text` and `html5` defaults to `false`. +Activates [Flatpickr](https://flatpickr.js.org/) on `DateType`, `TimeType`, `DateTimeType`, and `BirthdayType`. The +`widget` defaults to `single_text` and `html5` defaults to `false`. See [using-date-pickers.md](using-date-pickers.md) for options and examples. @@ -131,12 +134,12 @@ Adds drag-and-drop reordering and minimum/maximum item count validation to `Coll **Added options:** -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `allow_drag_and_drop` | `bool` | `true` | Enable drag-and-drop row reordering | -| `add_button_label` | `string` | `'forms.buttons.addItem'` | Translation key for the "Add item" button | -| `minimum_required_items` | `int` | `0` | Minimum number of items required | -| `maximum_required_items` | `int\|null` | `null` | Maximum number of items allowed (`null` = unlimited) | +| Option | Type | Default | Description | +|--------------------------|-------------|---------------------------|------------------------------------------------------| +| `allow_drag_and_drop` | `bool` | `true` | Enable drag-and-drop row reordering | +| `add_button_label` | `string` | `'forms.buttons.addItem'` | Translation key for the "Add item" button | +| `minimum_required_items` | `int` | `0` | Minimum number of items required | +| `maximum_required_items` | `int\|null` | `null` | Maximum number of items allowed (`null` = unlimited) | ```php $builder->add('contacts', CollectionType::class, [ @@ -154,13 +157,13 @@ Adds a show/hide toggle button to `PasswordType` fields. **Added options:** -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `toggle` | `bool` | `true` | Enable the show/hide toggle | -| `hidden_label` | `string\|null` | `'Hide password'` | Tooltip/label when password is visible | -| `visible_label` | `string\|null` | `'Show password'` | Tooltip/label when password is hidden | -| `button_classes` | `string[]` | `['toggle-password-button']` | CSS classes on the toggle button | -| `toggle_container_classes` | `string[]` | `['toggle-password-container']` | CSS classes on the wrapper element | +| Option | Type | Default | Description | +|----------------------------|----------------|---------------------------------|----------------------------------------| +| `toggle` | `bool` | `true` | Enable the show/hide toggle | +| `hidden_label` | `string\|null` | `'Hide password'` | Tooltip/label when password is visible | +| `visible_label` | `string\|null` | `'Show password'` | Tooltip/label when password is hidden | +| `button_classes` | `string[]` | `['toggle-password-button']` | CSS classes on the toggle button | +| `toggle_container_classes` | `string[]` | `['toggle-password-container']` | CSS classes on the wrapper element | To disable the toggle on a specific field: @@ -174,7 +177,8 @@ $builder->add('apiKey', PasswordType::class, [ ## Translations -Form field labels are translated automatically using the form's translation domain. The label key is derived from the field name (e.g. field `username` → key `Username`). +Form field labels are translated automatically using the form's translation domain. The label key is derived from the +field name (e.g. field `username` → key `Username`). To provide a custom translation, add a key to your translation file: diff --git a/docs/frontend-development.md b/docs/frontend-development.md index 481442a6..96af1bbe 100644 --- a/docs/frontend-development.md +++ b/docs/frontend-development.md @@ -1,21 +1,28 @@ # Frontend development ## Quick start -We use Symfony AssetMapper to manage our assets. See the [documentation](https://symfony.com/doc/current/frontend/asset_mapper.html) for more information. + +We use Symfony AssetMapper to manage our assets. See +the [documentation](https://symfony.com/doc/current/frontend/asset_mapper.html) for more information. + - Install assets with `symfony console importmap:install` - Build assets with `symfony console asset-map:compile` ## Sass + All Sass sources are included in `framework-core-bundle` bundle under `assets/scss/`. While developing, you can run `symfony console sass:build --watch` to automatically compile on change. ### Overriding Bootstrap variables -Use `assets/scss/_bootstrap-variables.scss` to override Bootstrap variables. Import this file in `assets/scss/style.scss` before the Bootstrap imports so your overrides take effect. -Use Bootstrap variables as much as possible to customize styling — this makes the code easier to maintain. +Use `assets/scss/_bootstrap-variables.scss` to override Bootstrap variables. Import this file in +`assets/scss/style.scss` before the Bootstrap imports so your overrides take effect. + +Use Bootstrap variables as much as possible to customize styling. This makes the code easier to maintain. #### Variables + - `$top-color` sets the top navbar background color - `$menu-bg` sets the sidebar background color - `$menu-color` sets the sidebar text color @@ -25,11 +32,15 @@ Use Bootstrap variables as much as possible to customize styling — this makes - `$secondary` sets the secondary color used in buttons, links, etc. ### Dark mode + We use Bootstrap’s dark mode implementation. See the [documentation](https://getbootstrap.com/docs/5.3/customize/color-modes/). -Most importantly, there are no separate dark mode files or stylesheets. Use `@include color-mode(dark) { ... }` to add dark mode styles. This mixin cannot be nested; put all dark mode styles in a single block at the end of the relevant SCSS file. +Most importantly, there are no separate dark mode files or stylesheets. Use `@include color-mode(dark) { ... }` to add +dark mode styles. This mixin cannot be nested; put all dark mode styles in a single block at the end of the relevant +SCSS file. #### Variables + - `$top-color-dark` sets the dark mode top navbar background color - `$menu-bg-dark` sets the dark mode sidebar background color - `$menu-color-dark` sets dark mode the sidebar text color @@ -37,8 +48,9 @@ Most importantly, there are no separate dark mode files or stylesheets. Use `@in - `$menu-active-color-dark` sets dark mode the sidebar active text color ### Custom components or extensions (Sass) -Place custom SCSS components in `assets/scss/components/`. Import your components after the Bootstrap imports. Try to base your custom components on Bootstrap components as much as possible. +Place custom SCSS components in `assets/scss/components/`. Import your components after the Bootstrap imports. Try to +base your custom components on Bootstrap components as much as possible. ### Folder overview @@ -54,20 +66,27 @@ Place custom SCSS components in `assets/scss/components/`. Import your component ## JavaScript -Most JavaScript in this bundle is provided as Stimulus controllers under `assets-public/controllers/` and utility modules under `assets-public/js/`. In your application, import the controllers in `assets/bootstrap.js`. Your `assets/app.js` typically acts as the main JavaScript entry. +Most JavaScript in this bundle is provided as Stimulus controllers under `assets-public/controllers/` and utility +modules under `assets-public/js/`. In your application, import the controllers in `assets/bootstrap.js`. Your +`assets/app.js` typically acts as the main JavaScript entry. ### Custom components or extensions (JavaScript) -Create a `components/` folder under your app’s JavaScript directory. Add your new component module (ES6 class/module) there and import it in `app.js`. -If you want to extend or replace an existing module, update the import in `bootstrap.js` to point to your new class. The new class can be completely different or extend a class from the existing assets. +Create a `components/` folder under your app’s JavaScript directory. Add your new component module (ES6 class/module) +there and import it in `app.js`. + +If you want to extend or replace an existing module, update the import in `bootstrap.js` to point to your new class. The +new class can be completely different or extend a class from the existing assets. ## Separate layout for the application’s frontend For a separate public/frontend layout, use a Bootstrap 5 setup. -Create a file `style-frontend.scss` in `assets/scss/`. You can import Bootstrap as explained in the Bootstrap [documentation](https://getbootstrap.com/docs/5.3/customize/sass/#importing). +Create a file `style-frontend.scss` in `assets/scss/`. You can import Bootstrap as explained in the +Bootstrap [documentation](https://getbootstrap.com/docs/5.3/customize/sass/#importing). -Don’t forget to add a new entry in `config/packages/symfonycasts_sass.yaml` for `style-frontend.scss` and include that entry in your frontend templates. +Don’t forget to add a new entry in `config/packages/symfonycasts_sass.yaml` for `style-frontend.scss` and include that +entry in your frontend templates. #### Import Bootstrap yourself @@ -77,11 +96,14 @@ Don’t forget to add a new entry in `config/packages/symfonycasts_sass.yaml` fo - Create `bootstrap-imports.scss` in the `frontend/` folder for the Bootstrap imports. - Import your variables file before the Bootstrap imports. - Import your components after the Bootstrap imports. -- You can copy starter files from `vendor/sumocoders/framework-core-bundle/assets/scss/` into your new `frontend/` folder. Update variables as needed or copy them from the original Bootstrap `_variables.scss` (found in `vendor/twbs/bootstrap/scss/`). If you copy these, remove the `!default` modifiers so your overrides take effect. +- You can copy starter files from `vendor/sumocoders/framework-core-bundle/assets/scss/` into your new `frontend/` + folder. Update variables as needed or copy them from the original Bootstrap `_variables.scss` (found in + `vendor/twbs/bootstrap/scss/`). If you copy these, remove the `!default` modifiers so your overrides take effect. ### Separate JavaScript -This works the same way as Sass. Create a new entry and a new collector file (for example: `app-frontend.js`) in a separate frontend JavaScript folder in your app. Load the correct entry in your frontend templates. +This works the same way as Sass. Create a new entry and a new collector file (for example: `app-frontend.js`) in a +separate frontend JavaScript folder in your app. Load the correct entry in your frontend templates. ## Upgrading from separate dark mode stylesheet diff --git a/docs/index.md b/docs/index.md index ce2e7886..fe1b46df 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,35 +1,42 @@ # FrameworkCoreBundle Documentation -`FrameworkCoreBundle` is a Symfony bundle providing shared scaffolding for SumoCoders projects: page titles, breadcrumbs, menus, forms, file/image uploads, audit logging, pagination, and a frontend design system. +`FrameworkCoreBundle` is a Symfony bundle providing shared scaffolding for SumoCoders projects: page titles, +breadcrumbs, menus, forms, file/image uploads, audit logging, pagination, and a frontend design system. -Install via the [application-skeleton](https://github.com/sumocoders/application-skeleton). PHP ^8.5, Symfony ^8.0, Doctrine ^3.3. +Install via the [application-skeleton](https://github.com/sumocoders/application-skeleton). + +Requirements: + +- PHP ^8.5, +- Symfony ^8.0, +- Doctrine ^3.3. --- ## Subsystems -| Doc | What it does | -|-----|-------------| -| [breadcrumb.md](breadcrumb.md) | `#[Breadcrumb]` attribute — builds breadcrumb trails from controller annotations | -| [title.md](title.md) | `#[Title]` attribute — sets the page `` and `<h1>` | -| [audit-trail.md](audit-trail.md) | `#[AuditTrail]` attribute — logs entity creates/updates/deletes | -| [pagination.md](pagination.md) | `Paginator` — wraps a Doctrine QueryBuilder for paginated results | -| [menu.md](menu.md) | `MenuBuilder` + `ConfigureMenuEvent` — KnpMenu-based navigation | -| [forms.md](forms.md) | Custom form types (`ImageType`, `FileType`, `BelgiumPostCodeType`) and type extensions | -| [uploading-files.md](uploading-files.md) | `AbstractFile` + DBAL type — file upload value objects wired to Doctrine | -| [uploading-images.md](uploading-images.md) | `AbstractImage` + DBAL type — image upload value objects with fallback support | -| [encrypted.md](encrypted.md) | `EncryptedDBALType` — transparent field-level encryption via libsodium | -| [mails.md](mails.md) | Bundle email base template and async dispatch pattern | -| [using-date-pickers.md](using-date-pickers.md) | Date/time picker form type extensions | -| [button-locations.md](button-locations.md) | Toolbar and form submit button placement conventions | -| [language-switch.md](language-switch.md) | Multi-locale navigation switcher | -| [installation.md](installation.md) | Frontend asset installation | -| [frontend-development.md](frontend-development.md) | SCSS variables, dark mode, JS components | -| [ajax-client.md](ajax-client.md) | Axios-based AJAX client with CSRF and toast support | -| [asset-mapper.md](asset-mapper.md) | Adding CSS/JS packages via Symfony AssetMapper | -| [dark-mode.md](dark-mode.md) | Dark mode support and how to disable it | -| [stimulus.md](stimulus.md) | Stimulus controllers provided by the bundle | -| [no-results.md](no-results.md) | Standard empty-state / no-results UI component | +| Doc | What it does | +|----------------------------------------------------|----------------------------------------------------------------------------------------| +| [breadcrumb.md](breadcrumb.md) | `#[Breadcrumb]` attribute, builds breadcrumb trails from controller annotations | +| [title.md](title.md) | `#[Title]` attribute, sets the page `<title>` and `<h1>` | +| [audit-trail.md](audit-trail.md) | `#[AuditTrail]` attribute, logs entity creates/updates/deletes | +| [pagination.md](pagination.md) | `Paginator`, wraps a Doctrine QueryBuilder for paginated results | +| [menu.md](menu.md) | `MenuBuilder` + `ConfigureMenuEvent`, KnpMenu-based navigation | +| [forms.md](forms.md) | Custom form types (`ImageType`, `FileType`, `BelgiumPostCodeType`) and type extensions | +| [uploading-files.md](uploading-files.md) | `AbstractFile` + DBAL type, file upload value objects wired to Doctrine | +| [uploading-images.md](uploading-images.md) | `AbstractImage` + DBAL type, image upload value objects with fallback support | +| [encrypted.md](encrypted.md) | `EncryptedDBALType`, transparent field-level encryption via libsodium | +| [mails.md](mails.md) | Bundle email base template and async dispatch pattern | +| [using-date-pickers.md](using-date-pickers.md) | Date/time picker form type extensions | +| [button-locations.md](button-locations.md) | Toolbar and form submit button placement conventions | +| [language-switch.md](language-switch.md) | Multi-locale navigation switcher | +| [installation.md](installation.md) | Frontend asset installation | +| [frontend-development.md](frontend-development.md) | SCSS variables, dark mode, JS components | +| [ajax-client.md](ajax-client.md) | Axios-based AJAX client with CSRF and toast support | +| [asset-mapper.md](asset-mapper.md) | Adding CSS/JS packages via Symfony AssetMapper | +| [dark-mode.md](dark-mode.md) | Dark mode support and how to disable it | +| [stimulus.md](stimulus.md) | Stimulus controllers provided by the bundle | +| [no-results.md](no-results.md) | Standard empty-state / no-results UI component | --- @@ -40,34 +47,34 @@ Install via the [application-skeleton](https://github.com/sumocoders/application ``` HTTP request └─ kernel.controller_arguments (priority -1) - ├─ BreadcrumbListener — reads #[Breadcrumb] from class + method, populates BreadcrumbTrail - └─ TitleListener — reads #[Title] from method, writes PageTitle - Falls back to BreadcrumbTrail if no #[Title] present + ├─ BreadcrumbListener reads #[Breadcrumb] from class + method, populates BreadcrumbTrail + └─ TitleListener reads #[Title] from method, writes PageTitle. Falls back to BreadcrumbTrail if no #[Title] present ``` ### Key injectable services -| Class | Purpose | Inject as | -|-------|---------|-----------| -| `SumoCoders\FrameworkCoreBundle\Service\BreadcrumbTrail` | Current request breadcrumbs (iterable) | `BreadcrumbTrail $breadcrumbTrail` | -| `SumoCoders\FrameworkCoreBundle\Service\PageTitle` | Computed page title string | `PageTitle $pageTitle` | -| `SumoCoders\FrameworkCoreBundle\Service\Fallbacks` | Global config fallbacks (site title etc.) | `Fallbacks $fallbacks` | -| `SumoCoders\FrameworkCoreBundle\Menu\MenuBuilder` | KnpMenu factory; dispatches `ConfigureMenuEvent` | `MenuBuilder $menuBuilder` | -| `SumoCoders\FrameworkCoreBundle\Pagination\Paginator` | Paginates a `QueryBuilder` | constructor injection | -| `SumoCoders\FrameworkCoreBundle\Logger\AuditLogger` | Writes audit log entries | `AuditLogger $auditLogger` | +| Class | Purpose | Inject as | +|----------------------------------------------------------|--------------------------------------------------|------------------------------------| +| `SumoCoders\FrameworkCoreBundle\Service\BreadcrumbTrail` | Current request breadcrumbs (iterable) | `BreadcrumbTrail $breadcrumbTrail` | +| `SumoCoders\FrameworkCoreBundle\Service\PageTitle` | Computed page title string | `PageTitle $pageTitle` | +| `SumoCoders\FrameworkCoreBundle\Service\Fallbacks` | Global config fallbacks (site title etc.) | `Fallbacks $fallbacks` | +| `SumoCoders\FrameworkCoreBundle\Menu\MenuBuilder` | KnpMenu factory; dispatches `ConfigureMenuEvent` | `MenuBuilder $menuBuilder` | +| `SumoCoders\FrameworkCoreBundle\Pagination\Paginator` | Paginates a `QueryBuilder` | constructor injection | +| `SumoCoders\FrameworkCoreBundle\Logger\AuditLogger` | Writes audit log entries | `AuditLogger $auditLogger` | ### PHP attributes -| Attribute | Target | What it does | -|-----------|--------|-------------| -| `#[Breadcrumb]` | method / class | Adds one crumb to the trail; repeatable; supports `parent:` chaining | -| `#[Title]` | method | Explicit page title with `{param}` interpolation | -| `#[AuditTrail]` | entity class | Enables Doctrine audit logging | -| `#[SensitiveData]` | entity property | Masks value in audit log as `*****` | +| Attribute | Target | What it does | +|--------------------|-----------------|----------------------------------------------------------------------| +| `#[Breadcrumb]` | method / class | Adds one crumb to the trail; repeatable; supports `parent:` chaining | +| `#[Title]` | method | Explicit page title with `{param}` interpolation | +| `#[AuditTrail]` | entity class | Enables Doctrine audit logging | +| `#[SensitiveData]` | entity property | Masks value in audit log as `*****` | ### Service configuration -All services are registered in `config/services.php` using PHP-format DI config. Autowiring and autoconfiguration are enabled. `Configuration.php` is intentionally empty — no runtime bundle config is needed. +All services are registered in `config/services.php` using PHP-format DI config. Autowiring and autoconfiguration are +enabled. `Configuration.php` is intentionally empty. No runtime bundle config is needed. --- @@ -75,10 +82,10 @@ All services are registered in `config/services.php` using PHP-format DI config. These packages are commonly used alongside this bundle. Consult their own documentation: -| Package | Docs | -|---------|------| -| `symfony/ux-autocomplete` | [Symfony UX Autocomplete](https://symfony.com/bundles/ux-autocomplete/current/index.html) | -| `doctrine/doctrine-fixtures-bundle` | [DoctrineFixturesBundle](https://symfony.com/bundles/DoctrineFixturesBundle/current/index.html) | +| Package | Docs | +|---------------------------------------|-----------------------------------------------------------------------------------------------------| +| `symfony/ux-autocomplete` | [Symfony UX Autocomplete](https://symfony.com/bundles/ux-autocomplete/current/index.html) | +| `doctrine/doctrine-fixtures-bundle` | [DoctrineFixturesBundle](https://symfony.com/bundles/DoctrineFixturesBundle/current/index.html) | | `doctrine/doctrine-migrations-bundle` | [DoctrineMigrationsBundle](https://symfony.com/bundles/DoctrineMigrationsBundle/current/index.html) | -| `dreadnip/chrome-pdf-bundle` | [chrome-pdf-bundle on GitHub](https://github.com/sanderdlm/chrome-pdf-bundle) | -| Deployer | Deployment config is application-specific — see your project's `deploy.php` | +| `dreadnip/chrome-pdf-bundle` | [chrome-pdf-bundle on GitHub](https://github.com/sanderdlm/chrome-pdf-bundle) | +| Deployer | Deployment config is application-specific, see your project's `deploy.php` | diff --git a/docs/installation.md b/docs/installation.md deleted file mode 100644 index 5968a5f1..00000000 --- a/docs/installation.md +++ /dev/null @@ -1,37 +0,0 @@ -# Frontend installation - -Install the frontend assets after cloning a project that uses this bundle. - -## Prerequisites - -- Node.js 20+ and npm 10+ -- PHP 8.5+ with Symfony CLI - -## Steps - -```bash -git clone <git-repo> -symfony composer install -npm install -npm run build -``` - -## Development - -Start the dev server with file watching: - -```bash -npm run watch -``` - -Or using Webpack Encore hot module replacement: - -```bash -npm run dev-server -``` - -## Troubleshooting - -- **`npm install` fails** — ensure you are on Node 20+: `node --version` -- **Assets not updating** — run `php bin/console cache:clear` after changing SCSS variables -- **Missing `@sumocoders/framework-style-package`** — the npm package must be listed in `package.json`; run `npm install` again diff --git a/docs/language-switch.md b/docs/language-switch.md index 5f3070f1..2ec7335d 100644 --- a/docs/language-switch.md +++ b/docs/language-switch.md @@ -1,6 +1,7 @@ # Language switch -The bundle supports multi-locale navigation. The active locale is part of the route URL via the `{_locale}` parameter. A dropdown in the navigation lets users switch language. +The bundle supports multi-locale navigation. The active locale is part of the route URL via the `{_locale}` parameter. A +dropdown in the navigation lets users switch language. ## Prerequisites @@ -12,7 +13,7 @@ Extend the `locales` parameter in `config/services.yaml`: ```yaml parameters: - locales: ['nl', 'fr', 'en'] + locales: [ 'nl', 'fr', 'en' ] ``` The `locales` parameter is passed to Twig and used in the language switcher dropdown. @@ -50,6 +51,9 @@ Add the following to `templates/navigation.html.twig`, between the logo and the ## Troubleshooting -- **Locale not changing on click** — verify your routes include `{_locale}` as a parameter or prefix; without it, `_locale` in `_route_params` has no effect -- **`locales` variable undefined in Twig** — ensure the `locales` parameter is defined in `config/services.yaml` under `parameters:` -- **Wrong locale active after switch** — check that the Symfony locale listener is active (`framework.translator.enabled_locales` in `config/packages/translation.yaml`) +- **Locale not changing on click**: verify your routes include `{_locale}` as a parameter or prefix; without it, + `_locale` in `_route_params` has no effect +- **`locales` variable undefined in Twig**: ensure the `locales` parameter is defined in `config/services.yaml` under + `parameters:` +- **Wrong locale active after switch**: check that the Symfony locale listener is active ( + `framework.translator.enabled_locales` in `config/packages/translation.yaml`) diff --git a/docs/mails.md b/docs/mails.md index f0c1c299..69ec0cf3 100644 --- a/docs/mails.md +++ b/docs/mails.md @@ -13,6 +13,7 @@ To send the mail, you can use the default Symfony package: `symfony/mailer`. See ## Async Emails will be sent via the async messenger transport, meaning: + 1. The context must be serializable 2. Run `console messenger:consume async` (default already in .crontab) @@ -34,13 +35,16 @@ template.html.twig ## Template styling -Mail templates use [Foundation for Emails](https://get.foundation/emails/docs/global.html) for responsive table-based layouts. Consult the Foundation docs for available components and helpers. +Mail templates use [Foundation for Emails](https://get.foundation/emails/docs/global.html) for responsive table-based +layouts. Consult the Foundation docs for available components and helpers. ### Logo -Use a logo with fixed width (182px) set directly on the `img` tag. Some mail clients cannot handle CSS-based image sizing. Use `.gif` extension for the broadest compatibility. +Use a logo with fixed width (182px) set directly on the `img` tag. Some mail clients cannot handle CSS-based image +sizing. Use `.gif` extension for the broadest compatibility. When you change the logo width, update the `width` attribute on the `img` tag to match. + ```php <?php diff --git a/docs/menu.md b/docs/menu.md index f7d68c28..69cb479a 100644 --- a/docs/menu.md +++ b/docs/menu.md @@ -1,6 +1,7 @@ # Menu -The bundle builds navigation using [KnpMenu](https://symfony.com/bundles/KnpMenuBundle/current/index.html). `MenuBuilder` dispatches a `ConfigureMenuEvent` — consuming apps listen to this event to add items. +The bundle builds navigation using [KnpMenu](https://symfony.com/bundles/KnpMenuBundle/current/index.html). +`MenuBuilder` dispatches a `ConfigureMenuEvent`, consuming apps listen to this event to add items. ## Prerequisites @@ -58,20 +59,20 @@ Register the listener in `config/services.yaml`: ```yaml services: - App\EventListener\MenuListener: - tags: - - { name: kernel.event_listener, event: framework_core.configure_menu, method: onConfigureMenu } + App\EventListener\MenuListener: + tags: + - { name: kernel.event_listener, event: framework_core.configure_menu, method: onConfigureMenu } ``` ## DefaultMenuListener helpers Extending `DefaultMenuListener` gives you three autowired services: -| Method | Returns | Purpose | -|--------|---------|---------| -| `$this->getTranslator()` | `TranslatorInterface` | Translate menu item labels | -| `$this->getSecurity()` | `Security` | Check roles/permissions | -| `$this->getRequestStack()` | `RequestStack` | Access current request | +| Method | Returns | Purpose | +|----------------------------|-----------------------|----------------------------| +| `$this->getTranslator()` | `TranslatorInterface` | Translate menu item labels | +| `$this->getSecurity()` | `Security` | Check roles/permissions | +| `$this->getRequestStack()` | `RequestStack` | Access current request | ## Active state for child routes @@ -130,6 +131,6 @@ $menu->addChild($paymentsItem); ## Troubleshooting -- **Menu item not highlighted** — add the route to `extras.routes` or use `enableChildRoutes` with the correct prefix -- **Item visible to wrong roles** — `isGranted` checks happen at render time; wrap the `addChild` call in a role check -- **Menu not rendering** — verify the listener is registered and tagged with `framework_core.configure_menu` +- **Menu item not highlighted**: add the route to `extras.routes` or use `enableChildRoutes` with the correct prefix +- **Item visible to wrong roles**: `isGranted` checks happen at render time; wrap the `addChild` call in a role check +- **Menu not rendering**: verify the listener is registered and tagged with `framework_core.configure_menu` diff --git a/docs/no-results.md b/docs/no-results.md index a094cdb5..eca515d8 100644 --- a/docs/no-results.md +++ b/docs/no-results.md @@ -1,6 +1,7 @@ # No-results state -Use this component when a list, datagrid, or page has no data to display — including after filtering produces zero results. +Use this component when a list, datagrid, or page has no data to display, including after filtering produces zero +results. ## Usage @@ -11,7 +12,8 @@ Use this component when a list, datagrid, or page has no data to display — inc </div> ``` -Replace `'your.translation.key'` with a translation key appropriate to the context (e.g. `'users.empty'`, `'orders.no_results'`). +Replace `'your.translation.key'` with a translation key appropriate to the context (e.g. `'users.empty'`, +`'orders.no_results'`). ## With a filter hint diff --git a/docs/pagination.md b/docs/pagination.md index 8d49787e..4e83c136 100644 --- a/docs/pagination.md +++ b/docs/pagination.md @@ -1,12 +1,13 @@ # Pagination -`Paginator` wraps a Doctrine `QueryBuilder` and handles page math, result slicing, and iteration. The default page size is 30. +`Paginator` wraps a Doctrine `QueryBuilder` and handles page math, result slicing, and iteration. The default page size +is 30. ## Usage ### Repository -Return a `Paginator` from the repository method. Do not call `paginate()` here — the controller does that. +Return a `Paginator` from the repository method. Do not call `paginate()` here, the controller does that. ```php <?php @@ -87,22 +88,22 @@ The `Paginator` is iterable and countable. Use the `pagination()` Twig function ## `Paginator` API reference -| Method | Return type | Description | -|--------|-------------|-------------| -| `paginate(int $page = 1)` | `self` | Executes the query for the given page; returns `$this` | -| `getCurrentPage()` | `int` | Current page number | -| `getLastPage()` | `int` | Last page number (= total pages) | -| `getPageSize()` | `int` | Items per page (default 30) | -| `hasPreviousPage()` | `bool` | Whether a previous page exists | -| `getPreviousPage()` | `int` | Previous page number (minimum 1) | -| `hasNextPage()` | `bool` | Whether a next page exists | -| `getNextPage()` | `int` | Next page number (maximum last page) | -| `hasToPaginate()` | `bool` | Whether there is more than one page | -| `getNumResults()` | `int` | Total number of results across all pages | -| `getResults()` | `Traversable` | Results for the current page | -| `calculateStartAndEndPage()` | `void` | Populates `startPage`/`endPage` (±3 around current page) for pager UI | -| `getStartPage()` | `int` | First page number in the pager window (after `calculateStartAndEndPage()`) | -| `getEndPage()` | `int` | Last page number in the pager window (after `calculateStartAndEndPage()`) | +| Method | Return type | Description | +|------------------------------|---------------|----------------------------------------------------------------------------| +| `paginate(int $page = 1)` | `self` | Executes the query for the given page; returns `$this` | +| `getCurrentPage()` | `int` | Current page number | +| `getLastPage()` | `int` | Last page number (= total pages) | +| `getPageSize()` | `int` | Items per page (default 30) | +| `hasPreviousPage()` | `bool` | Whether a previous page exists | +| `getPreviousPage()` | `int` | Previous page number (minimum 1) | +| `hasNextPage()` | `bool` | Whether a next page exists | +| `getNextPage()` | `int` | Next page number (maximum last page) | +| `hasToPaginate()` | `bool` | Whether there is more than one page | +| `getNumResults()` | `int` | Total number of results across all pages | +| `getResults()` | `Traversable` | Results for the current page | +| `calculateStartAndEndPage()` | `void` | Populates `startPage`/`endPage` (±3 around current page) for pager UI | +| `getStartPage()` | `int` | First page number in the pager window (after `calculateStartAndEndPage()`) | +| `getEndPage()` | `int` | Last page number in the pager window (after `calculateStartAndEndPage()`) | Custom page size: @@ -140,7 +141,8 @@ $users = $userRepository->getPaginated( ## Filters with session persistence -Without session storage, the filter resets when the user navigates to page 2. Store filter data in the session to persist it across page requests. +Without session storage, the filter resets when the user navigates to page 2. Store filter data in the session to +persist it across page requests. ```php <?php @@ -172,6 +174,8 @@ $request->getSession()->remove('user_filter'); ## Troubleshooting -- **Total count is wrong with JOINs** — the paginator sets `HINT_DISTINCT => false` when no JOINs are present. With JOINs, ensure your query does not produce duplicate root entities -- **`paginate()` not called** — always call `paginate()` before passing the paginator to the template; calling only the constructor does not execute the query -- **Page parameter missing** — use `$request->query->getInt('page', 1)` so an absent `?page=` defaults to page 1 +- **Total count is wrong with JOINs**: the paginator sets `HINT_DISTINCT => false` when no JOINs are present. With + JOINs, ensure your query does not produce duplicate root entities +- **`paginate()` not called**: always call `paginate()` before passing the paginator to the template; calling only the + constructor does not execute the query +- **Page parameter missing**: use `$request->query->getInt('page', 1)` so an absent `?page=` defaults to page 1 diff --git a/docs/stimulus.md b/docs/stimulus.md index a0292a2e..13e4d749 100644 --- a/docs/stimulus.md +++ b/docs/stimulus.md @@ -1,19 +1,21 @@ # Stimulus controllers -The bundle provides pre-built Stimulus controllers for common UI patterns. These are registered automatically when the frontend assets are installed. +The bundle provides pre-built Stimulus controllers for common UI patterns. These are registered automatically when the +frontend assets are installed. ## Prerequisites -Assets installed: see [installation.md](installation.md). Import the bundle's controllers in your `assets/controllers.json` or `assets/bootstrap.js`. +Assets installed: see [installation.md](installation.md). Import the bundle's controllers in your +`assets/controllers.json` or `assets/bootstrap.js`. ## Lifecycle hooks Every Stimulus controller supports: -| Method | When it runs | -|--------|-------------| -| `connect()` | When the controller's element is connected to the DOM | -| `disconnect()` | When the element is removed from the DOM | +| Method | When it runs | +|----------------|-------------------------------------------------------| +| `connect()` | When the controller's element is connected to the DOM | +| `disconnect()` | When the element is removed from the DOM | Custom controllers should call `super.connect()` if they extend a bundle controller. @@ -215,7 +217,6 @@ Set the `data-controller='busy-submit'` on the form tag or in your form type: ]); ``` - ## Confirm modal This controller allows you to show a confirmation modal before submitting a form or clicking a link. diff --git a/docs/title.md b/docs/title.md index 8308ce91..f1cf9917 100644 --- a/docs/title.md +++ b/docs/title.md @@ -1,14 +1,15 @@ # Page title -Sets the page `<title>` and `<h1>` from `#[Title]` attributes or, when absent, from the breadcrumb trail. Available in all Twig templates as the `pageTitle` variable. +Sets the page `<title>` and `<h1>` from `#[Title]` attributes or, when absent, from the breadcrumb trail. Available in +all Twig templates as the `pageTitle` variable. ## `#[Title]` options -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `title` | `string` | required | Page title. Supports `{param}` (scalar) and `{object.property}` (object) interpolation. Passed through the translator | -| `parent` | `array\|null` | `null` | Prepends the title of another route. Keys: `name` (required), `parameters` (optional array) | -| `extend` | `bool` | `true` | When `true`, appends ` - <site_title>`. When `false`, uses the string verbatim | +| Parameter | Type | Default | Description | +|-----------|---------------|----------|-----------------------------------------------------------------------------------------------------------------------| +| `title` | `string` | required | Page title. Supports `{param}` (scalar) and `{object.property}` (object) interpolation. Passed through the translator | +| `parent` | `array\|null` | `null` | Prepends the title of another route. Keys: `name` (required), `parameters` (optional array) | +| `extend` | `bool` | `true` | When `true`, appends ` - <site_title>`. When `false`, uses the string verbatim | The attribute targets **methods only** and is **not repeatable**. @@ -26,8 +27,8 @@ Set `fallback.site_title` in your `services.yaml`: ```yaml parameters: - fallbacks: - site_title: 'My Application' + fallbacks: + site_title: 'My Application' ``` ## The `#[Title]` attribute @@ -77,7 +78,8 @@ public function __invoke(): Response Output: `Detail - Overview - My Application` -The parent chain is resolved recursively: if the parent route also has a `#[Title]` with its own parent, that is included too. +The parent chain is resolved recursively: if the parent route also has a `#[Title]` with its own parent, that is +included too. ### Dynamic titles @@ -103,7 +105,8 @@ public function __invoke( } ``` -Dynamic parameters are resolved from the named controller arguments. If a placeholder is not found, an exception is thrown. +Dynamic parameters are resolved from the named controller arguments. If a placeholder is not found, an exception is +thrown. ### Disable automatic appending @@ -146,7 +149,10 @@ In Twig, `PageTitle` is available as a string (via `__toString`): ## Troubleshooting -- **`{param}` not resolving** — the placeholder must match the exact name of a controller argument. For objects, use `{object.property}` not `{object}` -- **Title missing site name** — verify `fallbacks.site_title` is set in `parameters` in `config/services.yaml` -- **Parent chain not working** — each route in the chain must exist and have `#[Title]` or `#[Breadcrumb]` attributes; the chain resolves by dispatching a subrequest to fetch the parent's title -- **`extend: false` still appends site title** — check that `extend:` is passed as a named argument: `#[Title('My Title', extend: false)]` +- **`{param}` not resolving**: the placeholder must match the exact name of a controller argument. For objects, use + `{object.property}` not `{object}` +- **Title missing site name**: verify `fallbacks.site_title` is set in `parameters` in `config/services.yaml` +- **Parent chain not working**: each route in the chain must exist and have `#[Title]` or `#[Breadcrumb]` attributes; + the chain resolves by dispatching a subrequest to fetch the parent's title +- **`extend: false` still appends site title**: check that `extend:` is passed as a named argument: + `#[Title('My Title', extend: false)]` diff --git a/docs/uploading-files.md b/docs/uploading-files.md index 74864f0b..d586bb6b 100644 --- a/docs/uploading-files.md +++ b/docs/uploading-files.md @@ -1,6 +1,7 @@ # Uploading files -The bundle provides `AbstractFile` — a value object that handles file storage, naming, and lifecycle hooks for Doctrine entities. Used with `FileType` for forms and a custom DBAL type for database persistence. +The bundle provides `AbstractFile`, a value object that handles file storage, naming, and lifecycle hooks for Doctrine +entities. Used with `FileType` for forms and a custom DBAL type for database persistence. ## Prerequisites @@ -9,7 +10,8 @@ The bundle provides `AbstractFile` — a value object that handles file storage, ## Step 1: Create the value object -Create a class that extends `AbstractFile` and implements `getUploadDir()`. The upload directory is relative to `public/files/`. +Create a class that extends `AbstractFile` and implements `getUploadDir()`. The upload directory is relative to +`public/files/`. ```php <?php @@ -57,9 +59,9 @@ Register it in `config/packages/doctrine.yaml`: ```yaml doctrine: - dbal: - types: - user_document: App\DBALType\UserDocumentType + dbal: + types: + user_document: App\DBALType\UserDocumentType ``` ## Step 3: Add to entity @@ -142,22 +144,26 @@ See [forms.md](forms.md) for the full `FileType` options reference. {% endif %} ``` -`AbstractFile` implements `__toString()` returning the web path (`/files/user/documents/<filename>`), or an empty string if no file exists. +`AbstractFile` implements `__toString()` returning the web path (`/files/user/documents/<filename>`), or an empty string +if no file exists. ## `AbstractFile` API -| Method | Description | -|--------|-------------| -| `getFileName()` | Raw stored filename | -| `getWebPath()` | Public URL path, or empty string if file missing | -| `getAbsolutePath()` | Absolute filesystem path | -| `hasFile()` | Whether a new `UploadedFile` is pending | -| `markForDeletion()` | Schedules the file for removal on next flush | -| `setNamePrefix(string)` | Prepends a slug to the generated filename | +| Method | Description | +|-------------------------|--------------------------------------------------| +| `getFileName()` | Raw stored filename | +| `getWebPath()` | Public URL path, or empty string if file missing | +| `getAbsolutePath()` | Absolute filesystem path | +| `hasFile()` | Whether a new `UploadedFile` is pending | +| `markForDeletion()` | Schedules the file for removal on next flush | +| `setNamePrefix(string)` | Prepends a slug to the generated filename | ## Troubleshooting -- **File not uploaded after form submit** — verify the three lifecycle methods (`prepareToUpload`, `upload`, `remove`) are present on the entity with the correct `#[ORM\*]` attributes -- **`public/files/` directory missing** — create it and ensure it is web-accessible; check your web server configuration -- **Old file not deleted on replace** — the `upload()` method deletes the old file; this only works if `prepareToUpload()` was called first in `PreUpdate` -- **Form always shows file as required** — `FileType` uses `required` based on whether the value object has an existing file; pass `'required' => false` to disable the constraint +- **File not uploaded after form submit**: verify the three lifecycle methods (`prepareToUpload`, `upload`, `remove`) + are present on the entity with the correct `#[ORM\*]` attributes +- **`public/files/` directory missing**: create it and ensure it is web-accessible; check your web server configuration +- **Old file not deleted on replace**: the `upload()` method deletes the old file; this only works if + `prepareToUpload()` was called first in `PreUpdate` +- **Form always shows file as required**: `FileType` uses `required` based on whether the value object has an existing + file; pass `'required' => false` to disable the constraint diff --git a/docs/uploading-images.md b/docs/uploading-images.md index 07a18206..b9d2cf33 100644 --- a/docs/uploading-images.md +++ b/docs/uploading-images.md @@ -1,6 +1,7 @@ # Uploading images -The bundle provides `AbstractImage` — extends `AbstractFile` with image-specific features: fallback images and web path resolution. Used with `ImageType` for forms and a custom DBAL type for database persistence. +The bundle provides `AbstractImage`, extends `AbstractFile` with image-specific features: fallback images and web path +resolution. Used with `ImageType` for forms and a custom DBAL type for database persistence. ## Prerequisites @@ -9,7 +10,8 @@ The bundle provides `AbstractImage` — extends `AbstractFile` with image-specif ## Step 1: Create the value object -Create a class that extends `AbstractImage` and implements `getUploadDir()`. Optionally override `FALLBACK_IMAGE` to return a web path for when no image is set. +Create a class that extends `AbstractImage` and implements `getUploadDir()`. Optionally override `FALLBACK_IMAGE` to +return a web path for when no image is set. ```php <?php @@ -59,9 +61,9 @@ Register it in `config/packages/doctrine.yaml`: ```yaml doctrine: - dbal: - types: - user_avatar: App\DBALType\UserAvatarType + dbal: + types: + user_avatar: App\DBALType\UserAvatarType ``` ## Step 3: Add to entity @@ -141,25 +143,32 @@ See [forms.md](forms.md) for the full `ImageType` options reference. <img src="{{ user.avatar.webPath }}" alt="{{ user.name }}"> ``` -`getWebPath()` returns the uploaded image URL or `FALLBACK_IMAGE` if no image has been uploaded. Returns an empty string if no fallback is defined and no file exists. +`getWebPath()` returns the uploaded image URL or `FALLBACK_IMAGE` if no image has been uploaded. Returns an empty string +if no fallback is defined and no file exists. ## `AbstractImage` API Inherits all `AbstractFile` methods plus: -| Method | Description | -|--------|-------------| -| `getWebPath()` | Public image URL, or `FALLBACK_IMAGE` if file missing | -| `getFallbackImage()` | Returns the value of `FALLBACK_IMAGE` constant | +| Method | Description | +|----------------------|-------------------------------------------------------| +| `getWebPath()` | Public image URL, or `FALLBACK_IMAGE` if file missing | +| `getFallbackImage()` | Returns the value of `FALLBACK_IMAGE` constant | ## Notes -- The bundle does not resize or optimize images. Handle resizing in the entity lifecycle callbacks or a post-upload event if needed. -- EXIF data is not stripped. For user-uploaded images consider stripping EXIF in the lifecycle callback before calling `upload()`. +- The bundle does not resize or optimize images. Handle resizing in the entity lifecycle callbacks or a post-upload + event if needed. +- EXIF data is not stripped. For user-uploaded images consider stripping EXIF in the lifecycle callback before calling + `upload()`. ## Troubleshooting -- **Image not uploaded after form submit** — verify the three lifecycle methods (`prepareToUpload`, `upload`, `remove`) are present on the entity -- **Fallback image not showing** — `FALLBACK_IMAGE` must be an absolute public path (e.g. `/images/no-avatar.png`), not a relative path -- **Old image not deleted on replace** — ensure `prepareToUpload()` is called in `PreUpdate`; it stores the old filename for deletion during `upload()` -- **Preview not showing in form** — the `ImageType` calls `getWebPath()` on the current value; check the file exists at the returned path +- **Image not uploaded after form submit**: verify the three lifecycle methods (`prepareToUpload`, `upload`, `remove`) + are present on the entity +- **Fallback image not showing**: `FALLBACK_IMAGE` must be an absolute public path (e.g. `/images/no-avatar.png`), not + a relative path +- **Old image not deleted on replace**: ensure `prepareToUpload()` is called in `PreUpdate`; it stores the old filename + for deletion during `upload()` +- **Preview not showing in form**: the `ImageType` calls `getWebPath()` on the current value; check the file exists at + the returned path diff --git a/docs/using-date-pickers.md b/docs/using-date-pickers.md index 26660db6..43da63d6 100644 --- a/docs/using-date-pickers.md +++ b/docs/using-date-pickers.md @@ -1,6 +1,7 @@ # Date and time pickers -The bundle uses [Flatpickr](https://flatpickr.js.org/) for date and time inputs. Flatpickr activates automatically on any field using these Symfony form types — no extra configuration required: +The bundle uses [Flatpickr](https://flatpickr.js.org/) for date and time inputs. Flatpickr activates automatically on +any field using these Symfony form types — no extra configuration required: - `DateType` - `TimeType` @@ -27,11 +28,11 @@ $builder->add('date', DateType::class, [ The bundle adds two convenience options to the date/time types: -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `minimum_date` | `string\|null` | `null` | Earliest selectable date, formatted as `d/m/Y` | -| `maximum_date` | `string\|null` | `null` | Latest selectable date, formatted as `d/m/Y` | -| `format` | `string` | `'dd/MM/yyyy'` | Display format (Flatpickr format string) | +| Option | Type | Default | Description | +|----------------|----------------|----------------|------------------------------------------------| +| `minimum_date` | `string\|null` | `null` | Earliest selectable date, formatted as `d/m/Y` | +| `maximum_date` | `string\|null` | `null` | Latest selectable date, formatted as `d/m/Y` | +| `format` | `string` | `'dd/MM/yyyy'` | Display format (Flatpickr format string) | ```php $builder->add('date', DateType::class, [ @@ -43,7 +44,8 @@ $builder->add('date', DateType::class, [ ## Passing additional Flatpickr options -Pass any [Flatpickr option](https://flatpickr.js.org/options/) as a `data-date-*` attribute, converting camelCase to kebab-case: +Pass any [Flatpickr option](https://flatpickr.js.org/options/) as a `data-date-*` attribute, converting camelCase to +kebab-case: - `minDate` → `data-date-min-date` - `showMonths` → `data-date-show-months` @@ -62,6 +64,8 @@ $builder->add('date', DateType::class, [ ## Troubleshooting -- **Picker does not open** — verify `widget` is not set to `choice` and `html5` is not `true` -- **Date format mismatch on submit** — the `format` option controls the display format; the form submits the underlying ISO value regardless of display format -- **`minimum_date`/`maximum_date` ignored** — these options must be a string in `d/m/Y` format matching the `format` option +- **Picker does not open**: verify `widget` is not set to `choice` and `html5` is not `true` +- **Date format mismatch on submit**: the `format` option controls the display format; the form submits the underlying + ISO value regardless of display format +- **`minimum_date`/`maximum_date` ignored**: these options must be a string in `d/m/Y` format matching the `format` + option From b6d415cf4e806fb5a37bd409701f33c2dbbcc60a Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen <tijs@verkoyen.eu> Date: Tue, 9 Jun 2026 17:52:04 +0200 Subject: [PATCH 9/9] feat: documented crud --- docs/crud.md | 934 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/index.md | 1 + 2 files changed, 935 insertions(+) create mode 100644 docs/crud.md diff --git a/docs/crud.md b/docs/crud.md new file mode 100644 index 00000000..83c5a954 --- /dev/null +++ b/docs/crud.md @@ -0,0 +1,934 @@ +# CRUD + +A standard CRUD in this bundle uses four invokable controllers, a shared DataTransferObject, a single form type, and +Symfony Messenger for mutations. + +## Prerequisites + +- Pagination in the repository (see [pagination.md](pagination.md)) +- Button placement conventions (see [button-locations.md](button-locations.md)) +- Ask whether audit logging is needed before adding `#[AuditTrail]` to the entity (see [audit-trail.md](audit-trail.md)) +- Ask whether a menu item should be added after the CRUD is in place (see [menu.md](menu.md)) + +## File structure + +``` +src/ + Controller/ + Item/ + Admin/ + IndexController.php + CreateController.php + UpdateController.php + DeleteController.php + DataTransferObject/ + Item/ + ItemDataTransferObject.php + Exception/ + Item/ + ItemNotFoundException.php + Message/ + Item/ + CreateItemMessage.php + UpdateItemMessage.php + DeleteItemMessage.php + MessageHandler/ + Item/ + CreateItemMessageHandler.php + UpdateItemMessageHandler.php + DeleteItemMessageHandler.php + Form/ + Item/ + ItemType.php + Entity/ + Item.php + Repository/ + ItemRepository.php +templates/ + item/ + index.html.twig + create.html.twig + update.html.twig +translations/ + messages.en.yaml +tests/ + MessageHandler/ + Item/ + CreateItemMessageHandlerTest.php + UpdateItemMessageHandlerTest.php + DeleteItemMessageHandlerTest.php + Controller/ + Item/ + Admin/ + IndexControllerTest.php + CreateControllerTest.php + UpdateControllerTest.php + DeleteControllerTest.php +``` + +## Entity + +The constructor is the only way to create an entity. The `update()` method takes explicit arguments, not a +DataTransferObject. + +```php +<?php + +namespace App\Entity; + +use App\Repository\ItemRepository; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity(repositoryClass: ItemRepository::class)] +class Item +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private ?int $id = null; + + public function __construct( + #[ORM\Column] + private string $name, + ) { + } + + public function update(string $name): void + { + $this->name = $name; + } + + public function getId(): ?int { return $this->id; } + public function getName(): string { return $this->name; } +} +``` + +## DataTransferObject + +`ItemDataTransferObject` holds the form fields for both create and update. Use the constructor to set initial values. + +```php +<?php + +namespace App\DataTransferObject; + +use Symfony\Component\Validator\Constraints\NotBlank; + +class ItemDataTransferObject +{ + public function __construct( + #[NotBlank] + public string $name = '', + ) { + } +} +``` + +## Messages + +Messages extend `ItemDataTransferObject`. `UpdateItemMessage` accepts the entity in its constructor and pre-fills the +DataTransferObject fields. The form is initialised with the message, so `$form->getData()` returns the message +directly and can be dispatched without conversion. + +```php +<?php + +namespace App\Message\Item; + +use App\DataTransferObject\ItemDataTransferObject; + +final class CreateItemMessage extends ItemDataTransferObject {} +``` + +```php +<?php + +namespace App\Message\Item; + +use App\DataTransferObject\ItemDataTransferObject; +use App\Entity\Item; + +final class UpdateItemMessage extends ItemDataTransferObject +{ + public readonly int $id; + + public function __construct( + Item $item, + ) { + $this->id = $item->getId(); + parent::__construct($item->getName()); + } +} +``` + +`DeleteItemMessage` accepts the entity but stores only the id. + +```php +<?php + +namespace App\Message\Item; + +use App\Entity\Item; + +final class DeleteItemMessage +{ + public readonly int $id; + + public function __construct( + Item $item, + ) { + $this->id = $item->getId(); + } +} +``` + +## Exception + +```php +<?php + +namespace App\Exception\Item; + +final class ItemNotFoundException extends \RuntimeException {} +``` + +## Form type + +A single `ItemType` is used for both create and update. + +```php +<?php + +namespace App\Form\Item; + +use App\DataTransferObject\ItemDataTransferObject; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** @extends AbstractType<ItemDataTransferObject> */ +final class ItemType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('name', TextType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => ItemDataTransferObject::class]); + } +} +``` + +## Repository + +`save()` and `remove()` accept an optional `$flush` parameter, defaulting to `true`. + +```php +<?php + +namespace App\Repository; + +use App\Entity\Item; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\Persistence\ManagerRegistry; +use SumoCoders\FrameworkCoreBundle\Pagination\Paginator; + +class ItemRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Item::class); + } + + public function getPaginated(): Paginator + { + return new Paginator( + $this->createQueryBuilder('i')->orderBy('i.name', 'ASC') + ); + } + + public function save(Item $item, bool $flush = true): void + { + $this->getEntityManager()->persist($item); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(Item $item, bool $flush = true): void + { + $this->getEntityManager()->remove($item); + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} +``` + +## Message handlers + +```php +<?php + +namespace App\MessageHandler\Item; + +use App\Entity\Item; +use App\Message\Item\CreateItemMessage; +use App\Repository\ItemRepository; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; + +#[AsMessageHandler] +final class CreateItemMessageHandler +{ + public function __construct( + private readonly ItemRepository $repository, + ) { + } + + public function __invoke(CreateItemMessage $message): void + { + $this->repository->save(new Item($message->name)); + } +} +``` + +```php +<?php + +namespace App\MessageHandler\Item; + +use App\Entity\Item; +use App\Exception\Item\ItemNotFoundException; +use App\Message\Item\UpdateItemMessage; +use App\Repository\ItemRepository; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; + +#[AsMessageHandler] +final class UpdateItemMessageHandler +{ + public function __construct( + private readonly ItemRepository $repository, + ) { + } + + public function __invoke(UpdateItemMessage $message): void + { + $item = $this->repository->find($message->id); + if (!$item instanceof Item) { + throw new ItemNotFoundException(); + } + + $item->update($message->name); + $this->repository->save($item); + } +} +``` + +```php +<?php + +namespace App\MessageHandler\Item; + +use App\Entity\Item; +use App\Exception\Item\ItemNotFoundException; +use App\Message\Item\DeleteItemMessage; +use App\Repository\ItemRepository; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; + +#[AsMessageHandler] +final class DeleteItemMessageHandler +{ + public function __construct( + private readonly ItemRepository $repository, + ) { + } + + public function __invoke(DeleteItemMessage $message): void + { + $item = $this->repository->find($message->id); + if (!$item instanceof Item) { + throw new ItemNotFoundException(); + } + + $this->repository->remove($item); + } +} +``` + +## Controllers + +Inject dependencies via the constructor. Use `$messageBus` as the variable name for `MessageBusInterface`. Place +`#[Route]` and `#[Breadcrumb]` attributes on `__invoke`, not on the class. + +### IndexController + +```php +<?php + +declare(strict_types=1); + +namespace App\Controller\Item\Admin; + +use App\Repository\ItemRepository; +use SumoCoders\FrameworkCoreBundle\Attribute\Breadcrumb; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +final class IndexController extends AbstractController +{ + public function __construct( + private readonly ItemRepository $repository, + ) { + } + + #[Route('/admin/items', name: 'item_index')] + #[Breadcrumb('item.breadcrumb.index')] + public function __invoke(Request $request): Response + { + $items = $this->repository->getPaginated() + ->paginate($request->query->getInt('page', 1)); + + return $this->render('item/index.html.twig', ['items' => $items]); + } +} +``` + +### CreateController + +```php +<?php + +declare(strict_types=1); + +namespace App\Controller\Item\Admin; + +use App\Form\Item\ItemType; +use App\Message\Item\CreateItemMessage; +use SumoCoders\FrameworkCoreBundle\Attribute\Breadcrumb; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Translation\TranslatorInterface; + +final class CreateController extends AbstractController +{ + public function __construct( + private readonly MessageBusInterface $messageBus, + private readonly TranslatorInterface $translator, + ) { + } + + #[Route('/admin/items/create', name: 'item_create')] + #[Breadcrumb('item.breadcrumb.index', route: ['name' => 'item_index'])] + #[Breadcrumb('item.breadcrumb.create')] + public function __invoke(Request $request): Response + { + $form = $this->createForm(ItemType::class, new CreateItemMessage()); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // @mago-expect analysis:mixed-argument + $this->messageBus->dispatch($form->getData()); + + $this->addFlash('success', $this->translator->trans('item.flash.created')); + + return $this->redirectToRoute('item_index'); + } + + return $this->render('item/create.html.twig', ['form' => $form]); + } +} +``` + +### UpdateController + +```php +<?php + +declare(strict_types=1); + +namespace App\Controller\Item\Admin; + +use App\Entity\Item; +use App\Form\Item\ItemType; +use App\Message\Item\UpdateItemMessage; +use SumoCoders\FrameworkCoreBundle\Attribute\Breadcrumb; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Translation\TranslatorInterface; + +final class UpdateController extends AbstractController +{ + public function __construct( + private readonly MessageBusInterface $messageBus, + private readonly TranslatorInterface $translator, + ) { + } + + #[Route('/admin/items/{item}/update', name: 'item_update')] + #[Breadcrumb('item.breadcrumb.index', route: ['name' => 'item_index'])] + #[Breadcrumb('{item.name}')] + public function __invoke(Request $request, Item $item): Response + { + $form = $this->createForm(ItemType::class, new UpdateItemMessage($item)); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // @mago-expect analysis:mixed-argument + $this->messageBus->dispatch($form->getData()); + + $this->addFlash('success', $this->translator->trans('item.flash.updated')); + + return $this->redirectToRoute('item_index'); + } + + $deleteForm = $this->createFormBuilder() + ->setAction($this->generateUrl('item_delete', ['item' => $item->getId()])) + ->getForm(); + + return $this->render('item/update.html.twig', [ + 'form' => $form, + 'item' => $item, + 'delete_form' => $deleteForm, + ]); + } +} +``` + +### DeleteController + +The delete form is validated to ensure the CSRF token is checked before dispatching. + +```php +<?php + +declare(strict_types=1); + +namespace App\Controller\Item\Admin; + +use App\Entity\Item; +use App\Message\Item\DeleteItemMessage; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Translation\TranslatorInterface; + +final class DeleteController extends AbstractController +{ + public function __construct( + private readonly MessageBusInterface $messageBus, + private readonly TranslatorInterface $translator, + ) { + } + + #[Route('/admin/items/{item}/delete', name: 'item_delete', methods: ['POST'])] + public function __invoke(Request $request, Item $item): Response + { + $deleteForm = $this->createFormBuilder() + ->getForm(); + $deleteForm->handleRequest($request); + + if ($deleteForm->isSubmitted() && $deleteForm->isValid()) { + $this->messageBus->dispatch(new DeleteItemMessage($item)); + + $this->addFlash('success', $this->translator->trans('item.flash.deleted')); + } + + return $this->redirectToRoute('item_index'); + } +} +``` + +## Templates + +### index.html.twig + +Use a table when the entity has multiple columns worth showing; use cards otherwise. + +```twig +{% extends 'layout.html.twig' %} + +{% block header_navigation %} + <a href="{{ path('item_create') }}" class="btn btn-primary"> + <i class="bi bi-plus"></i> + {{ 'item.actions.create'|trans }} + </a> +{% endblock %} + +{% block content %} + <table class="table"> + <thead> + <tr> + <th>{{ 'item.label.name'|trans }}</th> + <th class="text-end"></th> + </tr> + </thead> + <tbody> + {% for item in items %} + <tr> + <td>{{ item.name }}</td> + <td class="text-end"> + <a href="{{ path('item_update', {item: item.id}) }}" class="btn btn-sm btn-secondary"> + <i class="bi bi-pencil"></i> + {{ 'item.actions.update'|trans }} + </a> + </td> + </tr> + {% else %} + <tr> + <td colspan="2"> + <div class="data-no-results"> + <img src="{{ asset('images/no-results.svg') }}" alt=""> + {{ 'item.no_results'|trans }} + </div> + </td> + </tr> + {% endfor %} + </tbody> + </table> + + {% if items.hasToPaginate %} + <div class="d-flex justify-content-center"> + {{ pagination(items) }} + </div> + {% endif %} +{% endblock %} +``` + +### create.html.twig + +Add each form field separately with `form_row`. + +```twig +{% extends 'layout.html.twig' %} + +{% block content %} + {{ form_start(form) }} + {{ form_row(form.name) }} + {{ form_end(form) }} +{% endblock %} + +{% block header_actions_right %} + <button class="btn btn-primary" type="submit" form="{{ form.vars.id }}"> + <i class="bi bi-floppy-fill"></i> + {{ 'item.actions.save'|trans }} + </button> +{% endblock %} +``` + +### update.html.twig + +The delete button uses the `confirm` Stimulus controller (see [stimulus.md](stimulus.md)) and is placed in +`header_actions_left` because it is a destructive action. + +```twig +{% extends 'layout.html.twig' %} + +{% block content %} + {{ form_start(form) }} + {{ form_row(form.name) }} + {{ form_end(form) }} +{% endblock %} + +{% block header_actions_left %} + <div + {{ stimulus_controller( + 'confirm', + { + confirmationMessage: 'item.delete.confirm'|trans({'%item%': item.name}), + cancelButtonText: 'dialogs.buttons.cancel'|trans, + confirmButtonText: 'item.actions.delete'|trans, + } + ) }} + > + {{ form_start(delete_form, { attr: { 'data-confirm-target': 'element' }}) }} + <button class="btn btn-danger" type="submit"> + <i class="bi bi-trash"></i> + {{ 'item.actions.delete'|trans }} + </button> + {{ form_end(delete_form) }} + </div> +{% endblock %} + +{% block header_actions_right %} + <button class="btn btn-primary" type="submit" form="{{ form.vars.id }}"> + <i class="bi bi-floppy-fill"></i> + {{ 'item.actions.save'|trans }} + </button> +{% endblock %} +``` + +## Testing + +### Message handler tests + +Handler tests are unit tests. Mock the repository and assert the correct method is called. + +```php +<?php + +namespace App\Tests\MessageHandler\Item; + +use App\Entity\Item; +use App\Message\Item\CreateItemMessage; +use App\MessageHandler\Item\CreateItemMessageHandler; +use App\Repository\ItemRepository; +use PHPUnit\Framework\TestCase; + +final class CreateItemMessageHandlerTest extends TestCase +{ + public function testItCreatesAnItem(): void + { + $repository = $this->createMock(ItemRepository::class); + $repository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Item::class)); + + $message = new CreateItemMessage('Test item'); + + $handler = new CreateItemMessageHandler($repository); + $handler($message); + } +} +``` + +```php +<?php + +namespace App\Tests\MessageHandler\Item; + +use App\Entity\Item; +use App\Message\Item\UpdateItemMessage; +use App\MessageHandler\Item\UpdateItemMessageHandler; +use App\Repository\ItemRepository; +use PHPUnit\Framework\TestCase; + +final class UpdateItemMessageHandlerTest extends TestCase +{ + public function testItUpdatesAnItem(): void + { + $item = new Item('Old name'); + + $repository = $this->createMock(ItemRepository::class); + $repository->method('find')->willReturn($item); + $repository->expects($this->once()) + ->method('save') + ->with($item); + + $message = new UpdateItemMessage($item); + $message->name = 'New name'; + + $handler = new UpdateItemMessageHandler($repository); + $handler($message); + + static::assertSame('New name', $item->getName()); + } +} +``` + +```php +<?php + +namespace App\Tests\MessageHandler\Item; + +use App\Entity\Item; +use App\Exception\Item\ItemNotFoundException; +use App\Message\Item\DeleteItemMessage; +use App\MessageHandler\Item\DeleteItemMessageHandler; +use App\Repository\ItemRepository; +use PHPUnit\Framework\TestCase; + +final class DeleteItemMessageHandlerTest extends TestCase +{ + public function testItDeletesAnItem(): void + { + $item = new Item('Test item'); + + $repository = $this->createMock(ItemRepository::class); + $repository->method('find')->willReturn($item); + $repository->expects($this->once()) + ->method('remove') + ->with($item); + + $message = new DeleteItemMessage($item); + + $handler = new DeleteItemMessageHandler($repository); + $handler($message); + } + + public function testItThrowsWhenItemNotFound(): void + { + $repository = $this->createMock(ItemRepository::class); + $repository->method('find')->willReturn(null); + + $item = new Item('Test item'); + $message = new DeleteItemMessage($item); + + $this->expectException(ItemNotFoundException::class); + + $handler = new DeleteItemMessageHandler($repository); + $handler($message); + } +} +``` + +### Controller tests + +Controller tests are functional tests extending `WebTestCase`. They verify HTTP responses and redirects. + +```php +<?php + +namespace App\Tests\Controller\Item\Admin; + +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +final class IndexControllerTest extends WebTestCase +{ + public function testItRendersTheIndex(): void + { + $client = static::createClient(); + $client->request('GET', '/admin/items'); + + static::assertResponseIsSuccessful(); + } +} +``` + +```php +<?php + +namespace App\Tests\Controller\Item\Admin; + +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +final class CreateControllerTest extends WebTestCase +{ + public function testItRendersTheCreateForm(): void + { + $client = static::createClient(); + $client->request('GET', '/admin/items/create'); + + static::assertResponseIsSuccessful(); + } + + public function testItCreatesAnItemAndRedirects(): void + { + $client = static::createClient(); + $client->request('GET', '/admin/items/create'); + + $client->submitForm('item.actions.save', [ + 'item[name]' => 'Test item', + ]); + + static::assertResponseRedirects('/admin/items'); + } +} +``` + +```php +<?php + +namespace App\Tests\Controller\Item\Admin; + +use App\Entity\Item; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +final class UpdateControllerTest extends WebTestCase +{ + public function testItRendersTheUpdateForm(): void + { + $client = static::createClient(); + + $item = new Item('Original name'); + $em = static::getContainer()->get(EntityManagerInterface::class); + $em->persist($item); + $em->flush(); + + $client->request('GET', '/admin/items/' . $item->getId() . '/update'); + + static::assertResponseIsSuccessful(); + } + + public function testItUpdatesAnItemAndRedirects(): void + { + $client = static::createClient(); + + $item = new Item('Original name'); + $em = static::getContainer()->get(EntityManagerInterface::class); + $em->persist($item); + $em->flush(); + + $client->request('GET', '/admin/items/' . $item->getId() . '/update'); + + $client->submitForm('item.actions.save', [ + 'item[name]' => 'Updated name', + ]); + + static::assertResponseRedirects('/admin/items'); + } +} +``` + +```php +<?php + +namespace App\Tests\Controller\Item\Admin; + +use App\Entity\Item; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +final class DeleteControllerTest extends WebTestCase +{ + public function testItDeletesAnItemAndRedirects(): void + { + $client = static::createClient(); + + $item = new Item('To be deleted'); + $em = static::getContainer()->get(EntityManagerInterface::class); + $em->persist($item); + $em->flush(); + + $client->request('GET', '/admin/items/' . $item->getId() . '/update'); + $client->submitForm('item.actions.delete'); + + static::assertResponseRedirects('/admin/items'); + } +} +``` + +## Translations + +```yaml +# translations/messages.en.yaml +item: + breadcrumb: + index: 'Items' + create: 'Add item' + label: + name: 'Name' + flash: + created: 'The item has been created.' + updated: 'The item has been saved.' + deleted: 'The item has been deleted.' + actions: + create: 'Add item' + update: 'Update' + save: 'Save' + delete: 'Delete' + delete: + confirm: 'Are you sure you want to delete "%item%"?' + no_results: 'No items found.' +``` diff --git a/docs/index.md b/docs/index.md index fe1b46df..069eda3a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,7 @@ Requirements: | Doc | What it does | |----------------------------------------------------|----------------------------------------------------------------------------------------| +| [crud.md](crud.md) | Standard CRUD pattern: controllers, DTO, form type, templates, translations | | [breadcrumb.md](breadcrumb.md) | `#[Breadcrumb]` attribute, builds breadcrumb trails from controller annotations | | [title.md](title.md) | `#[Title]` attribute, sets the page `<title>` and `<h1>` | | [audit-trail.md](audit-trail.md) | `#[AuditTrail]` attribute, logs entity creates/updates/deletes |