Where Generated Code Stops

The generated CRUD has no authorisation, no limit on its query and no API shape; these are the changes to make before it ships.

Syntax#[Route('/{id}', name: 'app_article_show', methods: ['GET'], requirements: ['id' => '\d+'])]

The code make:crud writes runs, and that is all it claims. It was generated from the fields of one class, with no knowledge of who may use it or how much data it will meet. Four things are missing, and they are missing on purpose.

No authorisation

There is no access check anywhere in the generated controller. Anyone who can reach /article/7/edit can edit article 7, and anyone who can post the delete form can delete it. The CSRF token on the delete form proves the request came from your page; it says nothing about who sent it. Roles and voters are Stage 5; until then, assume these routes are public.

No limit on the query

index() calls findAll(), which loads every row in no defined order and renders one table line each. That is fine for twenty rows and a dead page at fifty thousand.

No API shape

Every action answers with HTML. If what you wanted was JSON, the generated show() is not a smaller version of it, it is a different thing.

No route constraints

The /{id} routes match anything, so /article/latest reaches show() with the string latest.

What to change first

  1. Decide who may reach each action.
  2. Replace findAll() with a bounded, ordered query.
  3. Decide the response shape, HTML or JSON.
  4. Constrain the route parameters.

Example

Example · php
<?php
// src/Controller/ArticleController.php - after the first two changes

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Article;
use App\Repository\ArticleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/article')]
final class ArticleController extends AbstractController
{
    // Generated: $articleRepository->findAll() - every row, no order.
    // Bounded: newest 20, by descending id. Real paging has its own lesson.
    #[Route(name: 'app_article_index', methods: ['GET'])]
    public function index(ArticleRepository $articleRepository): Response
    {
        return $this->render('article/index.html.twig', [
            'articles' => $articleRepository->findBy([], ['id' => 'DESC'], 20),
        ]);
    }

    // Generated: #[Route('/{id}', name: 'app_article_show', methods: ['GET'])]
    // requirements stops /article/latest from matching this route.
    #[Route('/{id}', name: 'app_article_show', methods: ['GET'], requirements: ['id' => '\d+'])]
    public function show(Article $article): Response
    {
        // $this->json($data) is shorthand. With no serializer installed it is
        // the same as: return new JsonResponse($data);
        // With the serializer installed it serialises $data first, which for a
        // flat array of scalars gives the same bytes.
        return $this->json([
            'id' => $article->getId(),
            'title' => $article->getTitle(),
        ]);
    }
}

// GET /article returns 200 with at most 20 rows in the table.
// GET /article/7 returns 200 with {"id":7,"title":"..."}.
// GET /article/latest returns 404: no route matches a non-numeric id.

When to use it

  • Lock a generated back-office controller behind a single role before the first deploy.
  • Bound the index query on a table that will grow past a page of results.
  • Turn a generated detail page into a JSON endpoint with an explicit field list.
  • Make a mistyped URL return 404 instead of reaching the controller with nonsense.

More examples

Closing the actions to everyone but one role

One attribute, placed on the class, is the smallest possible fix for the missing access check: a request without that role never reaches any method. What `ROLE_ADMIN` means, how a user comes to hold it, and how to express rules that depend on the article itself rather than the role, are the subject of the security stage. Add this now and treat it as a lock you will refine later, not as the finished rule.

Example · php
<?php
// src/Controller/ArticleAdminController.php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Article;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/admin/article')]
#[IsGranted('ROLE_ADMIN')] // on the class: checked for every method below
final class ArticleAdminController extends AbstractController
{
    #[Route('/{id}/edit', name: 'app_admin_article_edit', methods: ['GET', 'POST'])]
    public function edit(Article $article): Response
    {
        return $this->render('article_admin/edit.html.twig', [
            'article' => $article,
        ]);
    }
}

Trimming the generated index table

The generator writes one table column per field of the entity, because it has no way to know which fields are for you and which are for the reader. Delete the columns nobody needs; that is both a privacy fix and a speed fix. Leave the escaping alone while you do it: `{{ article.title }}` is escaped, and adding `|raw` to make some stored markup render turns a title into an injection point.

Example · twig
{# templates/article/index.html.twig - generated version, abridged #}
<td>{{ article.id }}</td>
<td>{{ article.title }}</td>
<td>{{ article.body }}</td>          {# the whole article body, in a cell #}
<td>{{ article.internalNotes }}</td> {# nobody asked for this to be public #}

{# reviewed version #}
<td><a href="{{ path('app_article_show', {'id': article.id}) }}">{{ article.title }}</a></td>
<td>{{ article.createdAt ? article.createdAt|date('Y-m-d') : '' }}</td>

The delete action the generator writes

Worth reading closely, because two parts of it are easy to break. The token comes from the request body, and the route accepts POST only, so this action cannot be triggered by a link or an image URL. The other part is what is absent: when the token does not match, the method falls through to the redirect and the caller sees the same 303 as a successful delete. If you want a failed delete to say so, that message is yours to add.

Example · php
// src/Controller/ArticleController.php (excerpt)
#[Route('/{id}', name: 'app_article_delete', methods: ['POST'])]
public function delete(Request $request, Article $article, EntityManagerInterface $entityManager): Response
{
    // The token is read from the POST body, not the query string:
    // getPayload() returns the decoded form or JSON body.
    if ($this->isCsrfTokenValid('delete'.$article->getId(), $request->getPayload()->getString('_token'))) {
        $entityManager->remove($article);
        $entityManager->flush(); // remove() schedules, flush() executes
    }

    return $this->redirectToRoute('app_article_index', [], Response::HTTP_SEE_OTHER);
}

Discussion

  • Be the first to comment on this lesson.