Repositories
The generated repository class, the finder methods it gives you, and why you inject a repository instead of the entity manager.
$repository->findBy(['status' => 'published'], ['publishedAt' => 'DESC'], 10, 0)A repository is a class with one job: fetching entities of a single type. Every query about Article lives in ArticleRepository and nowhere else. The generator writes the class; you add methods to it as the application needs them.
The generated class extends ServiceEntityRepository. Its constructor takes a ManagerRegistry — the object that knows which entity manager is responsible for which entity — and passes the entity class name up to the parent. That one line is what binds the repository to Article.
The finders you get for free
find($id)returns one entity by primary key, ornull.findOneBy(['slug' => $slug])returns the first match, ornull.findBy($criteria, $orderBy, $limit, $offset)returns an array of matches.findAll()returns every row, which is a trap on a large table.count(['status' => 'published'])returns an integer without loading anything.
Inject the repository, not the entity manager
The generated repository is registered as a service, so you type-hint it in a constructor and autowiring supplies it. A controller that needs articles asks for ArticleRepository. A controller holding EntityManagerInterface can query anything, and that is how query strings end up copied across half a dozen controllers.
Shorthand and longhand
$this->createQueryBuilder('a') is shorthand for $this->getEntityManager()->createQueryBuilder()->select('a')->from(Article::class, 'a'). Same object, same query.
What trips people up: criteria keys are entity property names, not column names. publishedAt, never published_at.
Example
<?php
// src/Repository/ArticleRepository.php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Article>
*/
class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
// The second argument is what ties this repository to one entity class.
parent::__construct($registry, Article::class);
}
public function findOneBySlug(string $slug): ?Article
{
return $this->findOneBy(['slug' => $slug]);
}
/**
* @return Article[]
*/
public function findPublished(int $limit = 10): array
{
// Keys are property names on Article, not database columns.
return $this->findBy(
['status' => 'published'],
['publishedAt' => 'DESC'],
$limit,
);
}
public function countPublished(): int
{
return $this->count(['status' => 'published']);
}
}When to use it
- Load one article by its slug for a show page and send a 404 when there is none.
- Give an admin dashboard a published-article count without loading the rows.
- Keep the definition of "published" in one repository method instead of in every controller that lists articles.
More examples
Injecting the repository into a controller
The repository arrives through the constructor, type-hinted on the class, so the controller cannot query anything else. GET /articles/first-post renders the template with status 200; an unknown slug produces a 404 page. The Route attribute comes from Symfony\Component\Routing\Attribute\Route, which is the namespace used since Symfony 6.4.
<?php
// src/Controller/ArticleController.php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ArticleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ArticleController extends AbstractController
{
public function __construct(
private readonly ArticleRepository $articles,
) {
}
#[Route('/articles/{slug}', name: 'app_article_show', methods: ['GET'])]
public function show(string $slug): Response
{
$article = $this->articles->findOneBySlug($slug);
if (null === $article) {
throw $this->createNotFoundException('No article with that slug.');
}
return $this->render('article/show.html.twig', ['article' => $article]);
}
}The same query written short and long
Both methods build the identical query. The short form is what you write; the long form is what you will meet in code that builds a query against several entities at once, or outside a repository. Recognising them as the same thing means you can move between them without re-reading the manual.
// src/Repository/ArticleRepository.php (two methods on the generated class)
/**
* @return Article[]
*/
public function findPublishedShort(): array
{
// createQueryBuilder('a') already knows the entity and sets the alias.
return $this->createQueryBuilder('a')
->andWhere('a.status = :status')
->setParameter('status', 'published')
->getQuery()
->getResult();
}
/**
* @return Article[]
*/
public function findPublishedLong(): array
{
// Exactly what the short form expands to.
return $this->getEntityManager()
->createQueryBuilder()
->select('a')
->from(Article::class, 'a')
->andWhere('a.status = :status')
->setParameter('status', 'published')
->getQuery()
->getResult();
}When findBy stops being enough
findBy matches on equality, so a date range is where it runs out. Keep the method on the repository rather than building the query in the controller: the next feature that needs recent articles then calls one method instead of copying five lines.
// src/Repository/ArticleRepository.php (method on the generated class)
/**
* findBy can only compare a property to a value or a list of values.
* A range, a LIKE, or an OR group needs a query.
*
* @return Article[]
*/
public function findPublishedSince(\DateTimeImmutable $since): array
{
return $this->createQueryBuilder('a')
->andWhere('a.status = :status')
->andWhere('a.publishedAt >= :since')
->setParameter('status', 'published')
->setParameter('since', $since)
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
Discussion