jmf / crud-engine-bundle
CRUD engine bundle for Symfony
Package info
github.com/jmfeurprier/crud-engine-bundle
Type:symfony-bundle
pkg:composer/jmf/crud-engine-bundle
Requires
- php: >=8.3
- doctrine/instantiator: ^2.0
- doctrine/orm: ^3.0
- jmf/template-rendering: ^1.0|^2.0
- symfony/doctrine-bridge: ^7.0|^8.0
- symfony/finder: ^7.0|^8.0
- symfony/form: ^7.0|^8.0
- symfony/framework-bundle: ^7.0|^8.0
- symfony/http-foundation: ^7.0|^8.0
- symfony/routing: ^7.0|^8.0
- symfony/security-bundle: ^7.0|^8.0
- symfony/yaml: ^7.0|^8.0
- twig/extra-bundle: ^3.0
- twig/string-extra: ^3.0
- webmozart/assert: ^1.0|^2.0
Requires (Dev)
- overtrue/phplint: ^9.7.1
- phpstan/phpstan: ^2.1.42
- phpstan/phpstan-strict-rules: ^2.0.10
- phpunit/phpunit: ^12.5.14|^13.0.5
- rector/rector: ^2.3.9
- squizlabs/php_codesniffer: ^4.0.1
This package is auto-updated.
Last update: 2026-06-20 12:17:27 UTC
README
A Symfony bundle that automates CRUD (Create, Read, Update, Delete) controllers and routes for Doctrine entities based on configuration, eliminating repetitive boilerplate code.
Requirements
- PHP 8.3+
- Symfony 7.0 or 8.0
- Doctrine ORM 3.0+
Installation
composer require jmf/crud-engine-bundle
Register the bundle in config/bundles.php if not using Symfony Flex:
return [ // ... Jmf\CrudEngine\JmfCrudEngineBundle::class => ['all' => true], ];
Quick Start
1. Configure the bundle
Create config/packages/jmf_crud_engine.yaml:
jmf_crud_engine: entities: App\Entity\Article: actions: create: redirection: route: article.index delete: redirection: route: dashboard index: read: update: redirection: route: article.index
2. Load the routes
In config/routes.yaml:
jmf_crud_engine: resource: 'Jmf\CrudEngine\Routing\RouteLoader' type: service
3. Create templates (optional)
Out of the box, any action whose template is missing renders a built-in bare template, so the bundle works immediately. To customize a view, create a Twig template at the configured path (e.g., templates/article/index.html.twig, templates/article/create.html.twig, etc.) and it takes precedence automatically.
Prefer to fail loudly instead of falling back? Set schema.view.fallback: fail (see Missing templates).
That's it: the bundle automatically registers routes and wires up controllers for all configured actions.
Actions
The bundle provides five actions, each mapped to a controller:
| Action | Default Route Path | HTTP Methods | Form | Description |
|---|---|---|---|---|
index |
/articles |
GET | No | Lists all entities |
read |
/articles/{id} |
GET | No | Displays a single entity |
create |
/articles/new |
GET, POST | Yes | Creates a new entity |
update |
/articles/{id}/edit |
GET, POST | Yes | Updates an existing entity |
delete |
/articles/{id}/delete |
GET, POST | No | Deletes an entity (POST confirms) |
Route paths and names are derived from the entity class name by default and can be overridden via configuration.
Configuration Reference
jmf_crud_engine: schema: # Default patterns for auto-discovering helper classes (Twig-style placeholders) helper: - "App\\Controller\\{{ EntityKey }}\\{{ ActionKey }}ActionHelper" - "App\\Controller\\{{ EntityKey }}{{ ActionKey }}ActionHelper" form: # Default patterns for auto-discovering form types type: - "App\\Form\\{{ EntityKey }}\\{{ ActionKey }}Type" - "App\\Form\\{{ EntityKey }}{{ ActionKey }}Type" - "App\\Form\\{{ EntityKey }}Type" # What to do when no form type is configured or discovered: # provide (default) -> build a generic form from the entity's Doctrine metadata # fail -> throw an exception fallback: provide # Default view path pattern view: path: "{{ entity_key }}/{{ action_key }}.html.twig" # What to do when a view template is not found: # provide (default) -> render the bundle's built-in bare template # fail -> throw an exception fallback: provide entities: App\Entity\Aricle: actions: index: ~ read: ~ create: # Override the form type (optional) form: type: App\Form\Article\CreateType # Override the helper service (optional) helper: App\Controller\Article\CreateActionHelper # Redirect after a successful form submission (required for create/update/delete) redirection: route: article.index parameters: id: "{{ _entity.id }}" # Twig expression using the entity fragment: section # Optional URL fragment # Override route configuration (optional) route: path: /blog/new parameters: {} requirements: id: '\d+' # Override template configuration (optional) view: path: article/create.html.twig variables: # Map template variable names to alternatives accepted in the template form: [articleForm, newArticleForm] update: redirection: route: article.index delete: redirection: route: dashboard
Template Placeholders
Configuration values and default patterns support the following placeholders:
Examples use the entity App\Entity\BlogPost: a two-word name, so the casing and pluralization differences are actually visible (with a single-word entity like Article they'd all look the same):
| Placeholder | Example (BlogPost) |
Description |
|---|---|---|
{{ EntityKey }} |
BlogPost |
Entity name, PascalCase |
{{ EntityKeys }} |
BlogPosts |
Entity name, pluralized |
{{ entityKey }} |
blogPost |
Entity name, camelCase |
{{ entityKeys }} |
blogPosts |
camelCase, pluralized |
{{ entity_key }} |
blog_post |
Entity name, snake_case |
{{ entity_keys }} |
blog_posts |
snake_case, pluralized |
{{ entitydashkey }} |
blog-post |
Entity name, kebab-case |
{{ entitydashkeys }} |
blog-posts |
kebab-case, pluralized (used in URLs) |
{{ ActionKey }} |
Create |
Action name, PascalCase |
{{ actionKey }} |
create |
Action name, camelCase |
{{ action_key }} |
create |
Action name, snake_case |
(Actions are always single words: index, read, create, update, delete, so their case variants only differ in capitalization; the actionKeys/actiondashkey/… variants exist for symmetry with the entity keys.)
Overriding placeholders (schema.keys)
These placeholders are not hard-coded: they are themselves defined as Twig patterns under schema.keys, and you can override them or add your own:
jmf_crud_engine: schema: keys: # Drop pluralization: route paths become "article/..." instead of "articles/...". entitydashkeys: "{{ entityClass|u.afterLast('\\\\').kebab }}" # Add a custom key, usable as "{{ EntityTitle }}" in any pattern below. # "App\Entity\BlogPost" -> "Blog Post" EntityTitle: "{{ entityClass|u.afterLast('\\\\').snake.replace({'_': ' '}).title(true) }}"
The contract:
- Overrides are merged over the defaults: declare only the keys you change.
- A key pattern may reference exactly two source variables:
entityClass(the FQCN) andaction(the action name, e.g.create), using Twig and the String componentu.*filters. - A key cannot reference another key (keys resolve only against the source variables). Doing so fails at container build time with a clear error.
- An unknown placeholder anywhere (a typo such as
{{ entity_keyz }}) also fails loudly at build time rather than silently rendering empty.
The runtime placeholder
{{ _entity.id }}used inredirection.parametersis not a key: it is resolved later, per request, against the actual entity, so it is unavailable in key/pattern definitions.
Per-entity configuration files (paths)
Instead of listing every entity under entities, you can keep one file per entity in a directory. Each file holds just the entity body (the actions: block); the entity FQCN is derived from the filename prefixed by a base namespace, so there is no jmf_crud_engine: / entities: / App\Entity\X: wrapper to repeat.
By default, this is enabled with zero configuration: the bundle loads <config-dir>/packages/<bundle-alias>/ (i.e. config/packages/jmf_crud_engine/) with the base namespace App\Entity. Just drop files in:
# config/packages/jmf_crud_engine/Article.yaml -> App\Entity\Article actions: index: read: create: redirection: route: article.index update: redirection: route: article.index delete: redirection: route: dashboard
The filename (without .yaml) is appended to the namespace, so Article.yaml → App\Entity\Article. Subdirectories map to sub-namespaces (Blog/Post.yaml → App\Entity\Blog\Post).
Customizing the directories
Override paths to point elsewhere or to scan several directories (each with its own base namespace). A leading empty namespace makes the directory layout mirror the full FQCN (App/Entity/Article.yaml).
jmf_crud_engine: paths: - path: '%kernel.project_dir%/config/crud' namespace: 'App\Entity' - path: '%kernel.project_dir%/config/crud-admin' namespace: 'App\Entity\Admin'
Setting paths explicitly replaces the default. paths and inline entities can be used together, but a given entity class must be defined in exactly one place: declaring it both via a file and inline (or across two scanned directories) throws CrudEngineDuplicateEntityException at container build time.
Notes
- Files support
!php/const ...(e.g. a routerequirementsvalue) and the empty-action shorthand (read:with no body), exactly like inline config. - As with inline config, the result is compiled into the container, so adding/editing/removing a file requires a container rebuild (
cache:clear) to take effect.
Action Helpers
Action helpers allow you to customize behavior at specific lifecycle hooks without replacing the entire controller. Create a class extending the matching *ActionHelperBase and register it as a Symfony service. Each base implements the full interface with sensible defaults (the same behavior the bundle uses when no helper is configured), so you override only the hooks you actually need.
If the class name matches a configured default pattern (e.g., App\Controller\Article\CreateActionHelper), it is picked up automatically. Otherwise, set helper explicitly in the action configuration.
The configuration (route names/paths, form type and helper auto-discovery, view paths, etc.) is resolved once, at container build time, and cached in the compiled container. As with routes, adding a helper/form-type class that matches a discovery pattern requires a container rebuild (
cache:clear) to be picked up.
Create Helper
use Jmf\CrudEngine\Controller\Helpers\CreateActionHelperBase; use Override; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; /** * @extends CreateActionHelperBase<Article> */ class ArticleCreateActionHelper extends CreateActionHelperBase { /** * Instantiate the new entity. The base does `new $entityClass()`; override only if the * entity needs constructor arguments. (The zero-config default uses the Doctrine Instantiator.) */ #[Override] public function createEntity(Request $request, string $entityClass): object { return new Article(); } /** * Called before the entity is persisted. */ #[Override] public function hookBeforePersist(Request $request, object $entity, FormInterface $form): void { // e.g. set timestamps, assign an owner } /** * Extra variables passed to the template. */ #[Override] public function getViewVariables(Request $request, object $entity): array { return []; } }
The base also provides persist() (Doctrine persist + flush) and hookAfterPersist(). Override them only to change persistence or run post-save side effects.
Update Helper
use Jmf\CrudEngine\Controller\Helpers\UpdateActionHelperBase; use Override; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; /** * @extends UpdateActionHelperBase<Article> */ class ArticleUpdateActionHelper extends UpdateActionHelperBase { /** * Called before the updated entity is persisted. */ #[Override] public function hookBeforePersist(Request $request, object $entity, FormInterface $form): void { // e.g. bump an "updated at" timestamp } /** * Extra variables passed to the template. */ #[Override] public function getViewVariables(Request $request, object $entity): array { return []; } }
Same hooks as the create helper, minus createEntity (the entity already exists). The default persist() here only flushes the already-managed entity; hookAfterPersist() is also available for post-save side effects.
Index Helper
use Doctrine\Persistence\ObjectManager; use Jmf\CrudEngine\Controller\Helpers\IndexActionHelperBase; use Override; use Symfony\Component\HttpFoundation\Request; /** * @extends IndexActionHelperBase<Article> */ class ArticleIndexActionHelper extends IndexActionHelperBase { /** * Return a custom collection. The base does `getRepository($entityClass)->findAll()`. */ #[Override] public function getEntities(Request $request, string $entityClass, ObjectManager $objectManager): iterable { return $objectManager->getRepository($entityClass)->findBy(['published' => true]); } #[Override] public function getViewVariables(Request $request): array { return []; } }
Read Helper
use Jmf\CrudEngine\Controller\Helpers\ReadActionHelperBase; use Override; use Symfony\Component\HttpFoundation\Request; /** * @extends ReadActionHelperBase<Article> */ class ArticleReadActionHelper extends ReadActionHelperBase { #[Override] public function getViewVariables(Request $request, object $entity): array { return []; } }
Delete Helper
use Jmf\CrudEngine\Controller\Helpers\DeleteActionHelperBase; use Override; use Symfony\Component\HttpFoundation\Request; /** * @extends DeleteActionHelperBase<Article> */ class ArticleDeleteActionHelper extends DeleteActionHelperBase { #[Override] public function hookBeforeRemove(object $entity): void { // e.g. guard against deleting a referenced entity } #[Override] public function getViewVariables(Request $request, object $entity): array { return []; } }
The base also provides remove() (Doctrine remove + flush), hookAfterRemove(), and onFailure() (re-throws the failure). Override them as needed.
Templates
Templates receive the entity (or collection) as a variable. The variable name is derived from the entity key (e.g., article for App\Entity\Article, articles for the index action).
Forms are passed as form (a FormView instance).
Example: templates/article/index.html.twig
{% for article in articles %}
<h2>{{ article.title }}</h2>
{% endfor %}
Example: templates/article/create.html.twig
{{ form_start(form) }}
{{ form_widget(form) }}
<button type="submit">Create</button>
{{ form_end(form) }}
Missing templates
When the resolved template for an action does not exist, the behavior is controlled by schema.view.fallback:
| Value | Behavior |
|---|---|
provide (default) |
Renders the bundle's built-in bare template (@JmfCrudEngine/{action}.html.twig). |
fail |
Throws CrudEngineMissingViewException. |
The built-in templates are intentionally minimal: they exist to get pages rendering immediately and to be overridden. Providing your own template at the configured path always takes precedence over the built-in one.
Missing form types
For create/update, when no form type is configured (form.type) or discovered (via the schema.form.type patterns), the behavior is controlled by schema.form.fallback:
| Value | Behavior |
|---|---|
provide (default) |
Builds a generic form from the entity's Doctrine metadata (CrudEngineEntityType). |
fail |
Throws CrudEngineMissingConfigurationException. |
The generic form is a scaffold to be overridden: it maps scalar columns and enumType fields, and renders to-one associations as a choice of related entities; it skips identifiers, embeddables, to-many associations, and unmappable column types. Configuring or discovering a real form type always takes precedence.
To make the fallback visible wherever it renders (including under a custom template), the generated form prepends a disabled, read-only "Generated fallback form" field naming the form type class to implement to replace it.
License
MIT