Building It
Wiring a controller, a form, an entity and a service into one project, and why each class sits where it does.
#[Route('/articles/new', name: 'app_article_new', methods: ['GET', 'POST'])]Building the capstone means wiring four things that already work on their own: a controller that answers a request, a form that fills an entity, Doctrine that stores it, and a service holding the one rule neither of them owns. What is new here is that they share a single project with a fixed layout.
The layout, and the part of it that is not just convention
Controllers in src/Controller, mapped classes in src/Entity, queries in src/Repository, form types in src/Form, your own logic in src/Service, templates in templates/, configuration in config/packages. Most of that is habit, and habit is worth keeping. One part is not habit: config/services.yaml registers every class under src/ as a service, which is what makes constructor injection work. A class placed outside src/ is still autoloaded by Composer, but it is not a service, so nothing can inject it.
How one request moves through it
The router matches /articles/new. The controller builds the form from ArticleForm. handleRequest() copies submitted fields onto the Article object and runs validation. persist() schedules the insert, flush() writes it. The controller redirects, and the browser issues a fresh GET.
What trips people up
A controller that slowly acquires business rules. Slug generation, publishing dates, anything you would want to test without HTTP, belongs in a class under src/Service, injected on the constructor. Shorthand worth recognising: 'form' => $form in a template's variables does the same as 'form' => $form->createView(), because Symfony calls createView() for you.
Example
<?php
// src/Controller/ArticleController.php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Article;
use App\Form\ArticleForm;
use App\Repository\ArticleRepository;
use App\Service\ArticleSlugger;
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('/articles')]
final class ArticleController extends AbstractController
{
// Services arrive through the constructor, typed on what they are.
public function __construct(
private readonly ArticleRepository $articles,
private readonly EntityManagerInterface $entityManager,
private readonly ArticleSlugger $slugger,
) {
}
#[Route('', name: 'app_article_index', methods: ['GET'])]
public function index(): Response
{
// One query, author joined in: see the repository example below.
return $this->render('article/index.html.twig', [
'articles' => $this->articles->findPublishedWithAuthor(),
]);
}
#[Route('/new', name: 'app_article_new', methods: ['GET', 'POST'])]
public function new(Request $request): Response
{
$article = new Article();
$form = $this->createForm(ArticleForm::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$article->setSlug($this->slugger->slugify($article->getTitle()));
$this->entityManager->persist($article); // schedules the insert
$this->entityManager->flush(); // writes it
return $this->redirectToRoute('app_article_index');
}
// 'form' => $form is shorthand for 'form' => $form->createView().
return $this->render('article/new.html.twig', ['form' => $form]);
}
}
// GET /articles returns 200 with the list page.
// POST /articles/new with valid fields returns 302 to /articles;
// with invalid fields it returns 422 and re-renders the form with errors.
When to use it
- Turn a plan into the first working create-and-list screens
- Decide whether a new piece of logic belongs in the controller, the entity or a service
- Fix a list page that issues dozens of queries by moving the join into the repository
More examples
The service the controller delegates to
A rule with no HTTP in it, in a class with one job. It is injectable anywhere, and its test is three lines with no kernel booted. Move code here as soon as a controller method grows a second reason to change.
<?php
// src/Service/ArticleSlugger.php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\String\Slugger\SluggerInterface;
final class ArticleSlugger
{
// Type-hinted on the interface, so a test can pass a different one.
public function __construct(private readonly SluggerInterface $slugger)
{
}
public function slugify(string $title): string
{
return $this->slugger->slug($title)->lower()->toString();
}
}
// $slugger->slugify('Shipping Day: Round Two');
// Output:
// shipping-day-round-twoThe form type that fills the entity
The form lists fields and nothing else. The rules about what is acceptable live as validation constraints on the entity, so the API and a console importer get the same checks without repeating them. Build one of these per write operation, not per entity, once two screens need different fields.
<?php
// src/Form/ArticleForm.php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Article;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class ArticleForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class)
->add('body', TextareaType::class, ['required' => false]);
}
public function configureOptions(OptionsResolver $resolver): void
{
// data_class makes handleRequest() write onto an Article instance.
$resolver->setDefaults(['data_class' => Article::class]);
}
}The query that keeps the list to one trip
Without addSelect('u'), printing the author's name in the template fires one extra query per row. The join plus addSelect is the fix, and it belongs in the repository so every caller gets it. Use this shape for any list template that shows a value from a related record.
<?php
// src/Repository/ArticleRepository.php (excerpt)
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Article>
*/
final class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
/** @return list<Article> */
public function findPublishedWithAuthor(): array
{
return $this->createQueryBuilder('a')
->addSelect('u') // fetch the author in the same query
->innerJoin('a.author', 'u')
->andWhere('a.publishedAt IS NOT NULL')
->orderBy('a.publishedAt', 'DESC')
->setMaxResults(20)
->getQuery()
->getResult();
}
}
Discussion