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.

Syntaxphp 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] attribute
  • src/Form/ArticleType.php - one form class, one add() call per field
  • templates/article/index.html.twig, show.html.twig, new.html.twig, edit.html.twig, and the partials _form.html.twig and _delete_form.html.twig
  • tests/Controller/ArticleControllerTest.php, if you pass --with-tests or 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

Example · php
<?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.

Example · twig
{# 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.

Example · php
<?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.

Example · bash
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

  • Be the first to comment on this lesson.