Generating a CRUD
make:crud writes a controller, a form class and six templates for an entity you already have; this is every file and what is in it.
php bin/console make:crud [entity-class] [--controller-class=NAME] [--with-tests]make:crud takes a class that already exists in src/Entity and writes a working set of pages for it. CRUD stands for create, read, update, delete. The entity must exist first: the command reads its fields to decide what to generate.
Every file it writes
For php bin/console make:crud Article:
src/Controller/ArticleController.php- five methods, each with a#[Route]attributesrc/Form/ArticleType.php- one form class, oneadd()call per fieldtemplates/article/index.html.twig,show.html.twig,new.html.twig,edit.html.twig, and the partials_form.html.twigand_delete_form.html.twigtests/Controller/ArticleControllerTest.php, if you pass--with-testsor answer yes when asked
How the controller is put together
The class carries #[Route('/article')], so each method path is relative to that prefix. Route names are prefixed with app_: app_article_index, app_article_new, app_article_show, app_article_edit, app_article_delete. Services arrive as method arguments, which Symfony fills in; Stage 2 explains that mechanism. index() gets the entity's repository class when it has one.
The delete action
delete() answers POST on /{id}, the same path show() answers with GET. It checks a CSRF token from the request body before removing anything, then redirects with status 303. That is why _delete_form.html.twig is a form and not a link.
Example
<?php
// src/Controller/ArticleController.php - as generated, two methods of five
declare(strict_types=1); // the generator does not add this line
namespace App\Controller;
use App\Entity\Article;
use App\Form\ArticleType;
use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/article')] // prefix for every route in this class
final class ArticleController extends AbstractController
{
// No path argument, so this method answers the prefix alone: GET /article
#[Route(name: 'app_article_index', methods: ['GET'])]
public function index(ArticleRepository $articleRepository): Response
{
return $this->render('article/index.html.twig', [
'articles' => $articleRepository->findAll(),
]);
}
#[Route('/new', name: 'app_article_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$article = new Article();
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($article); // schedules the insert
$entityManager->flush(); // sends it to the database
return $this->redirectToRoute('app_article_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('article/new.html.twig', [
'article' => $article,
'form' => $form,
]);
}
// show(), edit() and delete() follow and are left out here for length
}
// GET /article returns 200 with the generated table of every row.
// GET /article/new returns 200 with the empty form.
// POST /article/new with valid input returns 303 to /article; with invalid
// input it returns 200 and re-renders the form with the error messages.
// The generator sets no status on that re-render, so if you want the usual
// 422 you pass it yourself: $this->render($template, $vars,
// new Response(null, Response::HTTP_UNPROCESSABLE_ENTITY)).When to use it
- Stand up back-office screens for a model nobody outside the team will see.
- Produce a correct reference controller to read when you are new to forms and routing.
- Generate admin pages under their own route prefix with --controller-class.
- Get a first functional test file for a controller with --with-tests.
More examples
The generated form partial, and the same markup written out
Both files are included by `new.html.twig` and `edit.html.twig`, which is why the partial exists at all: one copy of the markup for two pages. Start with the generated short form; move to the `form_row()` list as soon as you need to reorder fields, wrap one in a column, or put two side by side.
{# templates/article/_form.html.twig - exactly as generated #}
{{ form_start(form) }}
{{ form_widget(form) }}
<button class="btn">{{ button_label|default('Save') }}</button>
{{ form_end(form) }}
{# The longhand. form_widget(form) on the root form renders every child that
has not been rendered yet, each with its label and its errors. Naming the
fields with form_row() produces the same markup, one field at a time. #}
{{ form_start(form) }}
{{ form_row(form.title) }}
{{ form_row(form.body) }}
<button class="btn">Save</button>
{{ form_end(form) }}The generated form class
One `add()` call per field of the entity, with no field type given, so each type is worked out at runtime from the entity's own definition. `data_class` is what binds the form to `Article`, so a submitted form fills an `Article` object rather than handing you an array. Note what this means: the generator will happily add a field like `createdAt` that no user should be typing.
<?php
// src/Form/ArticleType.php - as generated
namespace App\Form;
use App\Entity\Article;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ArticleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title')
->add('body')
->add('createdAt')
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Article::class,
]);
}
}Naming the controller and asking for tests
Passing `--controller-class` skips the question the command would otherwise ask, which is what you want in a script or a non-interactive terminal. It also moves everything else: the route prefix becomes `/article/admin`, the route names become `app_article_admin_*`, and the templates land in `templates/article_admin/`, all derived from the controller name rather than the entity name.
php bin/console make:crud Article --controller-class=ArticleAdminController --with-tests
# created: src/Controller/ArticleAdminController.php
# created: src/Form/ArticleType.php
# created: templates/article_admin/_delete_form.html.twig
# created: templates/article_admin/_form.html.twig
# created: templates/article_admin/edit.html.twig
# created: templates/article_admin/index.html.twig
# created: templates/article_admin/new.html.twig
# created: templates/article_admin/show.html.twig
# created: tests/Controller/ArticleControllerTest.php
Discussion