Symfony is the reference PHP framework in enterprise. This tutorial creates a complete blog application: Doctrine entities with migrations, RESTful controllers, forms with validation, Twig templates and user authentication.
Definition: Separation of the application into three layers: Model (data), View (presentation), Controller (business logic).
Purpose: Organize code so each layer has a single responsibility, making maintenance and testing easier.
Why here: Symfony enforces this pattern: HTTP request โ Router โ Controller โ Model/ORM โ View (Twig template) โ HTML/JSON response.
# Install Symfony CLI
curl -sS https://get.symfony.com/cli/installer | bash
# Create a complete web project
symfony new my-blog --version="7.*" --webapp
cd my-blog
# Start the development server
symfony server:start -d
# Install required bundles
composer require security
composer require form validator
composer require twig/twig
composer require knplabs/knp-paginator-bundle
# SQLite for development
DATABASE_URL="sqlite:///%kernel.project_dir%/var/app.db"
# PostgreSQL for production
# DATABASE_URL="postgresql://user:password@127.0.0.1:5432/blog?serverVersion=16"
Definition: PHP class mapped to a database table via Doctrine ORM. Each property corresponds to a column.
Purpose: Represent business data in PHP rather than with raw SQL.
Why here: Doctrine uses PHP annotations/attributes to map properties to columns and define relationships, validations and callbacks.
Definition: Abstraction layer that translates PHP objects to SQL queries and vice versa.
Purpose: Write business code in PHP without touching SQL, for maintainability and security (SQL injection prevention).
Why here: Doctrine is the reference PHP ORM, offering lazy loading, lazy evaluation and type-safe queries.
# Create the Article entity interactively
php bin/console make:entity Article
# Enter the fields:
# title โ string(255) not null
# content โ text not null
# excerpt โ string(500) nullable
# slug โ string(255) not null unique
# publishedAt โ datetime nullable
# views โ integer default 0
<?php
namespace App\Entity;
use App\Repository\ArticleRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['slug'], message: 'This slug is already used')]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'Title is required')]
#[Assert\Length(max: 255)]
private string $title = '';
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
#[Assert\Length(min: 10)]
private string $content = '';
#[ORM\Column(length: 255, unique: true)]
private string $slug = '';
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $publishedAt = null;
#[ORM\Column(options: ['default' => 0])]
private int $views = 0;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\PrePersist]
public function onPrePersist(): void
{
$this->createdAt = new \DateTimeImmutable();
$this->generateSlug();
}
private function generateSlug(): void
{
$this->slug = strtolower(trim(preg_replace(
'/[^A-Za-z0-9-]+/', '-', $this->title
), '-'));
}
public function isPublished(): bool
{
return $this->publishedAt !== null && $this->publishedAt <= new \DateTimeImmutable();
}
// Getters and setters auto-generated...
public function getId(): ?int { return $this->id; }
public function getTitle(): string { return $this->title; }
public function setTitle(string $title): static { $this->title = $title; return $this; }
public function getContent(): string { return $this->content; }
public function setContent(string $content): static { $this->content = $content; return $this; }
public function getSlug(): string { return $this->slug; }
public function getPublishedAt(): ?\DateTimeImmutable { return $this->publishedAt; }
public function setPublishedAt(?\DateTimeImmutable $date): static { $this->publishedAt = $date; return $this; }
public function getViews(): int { return $this->views; }
public function incrementViews(): static { $this->views++; return $this; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
}
Definition: Class containing SQL queries (via QueryBuilder) for an entity. It encapsulates data access logic.
Purpose: Keep query logic out of the controller, for reusability and testability.
Why here: Instead of writing SQL in the controller, call $this->articleRepository->findPublished(), a readable and reusable business method.
<?php
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
/** Returns published articles, sorted by publication date */
public function findPublished(int $limit = 10): array
{
return $this->createQueryBuilder('a')
->where('a.publishedAt IS NOT NULL')
->andWhere('a.publishedAt <= :now')
->setParameter('now', new \DateTimeImmutable())
->orderBy('a.publishedAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/** Full-text search in title and content */
public function search(string $query): array
{
return $this->createQueryBuilder('a')
->where('a.title LIKE :q OR a.content LIKE :q')
->setParameter('q', '%' . $query . '%')
->andWhere('a.publishedAt IS NOT NULL')
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
}
createQueryBuilder('a') creates a query on Article aliased 'a'. Methods where(), orderBy(), getResult() correspond to SELECT, WHERE, ORDER BY, FETCH.Definition: Class containing actions (public methods) that handle HTTP requests and return responses.
Purpose: Orchestrate logic: fetch data from repository, execute business logic, call the view.
Why here: Actions are mapped to routes via #[Route(...)] attributes. Symfony automatically injects dependencies (repository, EntityManager).
Definition: Technique where the application provides necessary objects (dependencies) to classes instead of them creating it themselves.
Purpose: Decouple dependencies, simplify tests (mock dependencies) and promote reusability.
Why here: Symfony injects ArticleRepository and EntityManagerInterface directly into actions, without manual instantiation.
<?php
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('/articles')]
class ArticleController extends AbstractController
{
#[Route('/', name: 'article_index', methods: ['GET'])]
public function index(ArticleRepository $repo): Response
{
return $this->render('article/index.html.twig', [
'articles' => $repo->findPublished(),
]);
}
#[Route('/{slug}', name: 'article_show', methods: ['GET'])]
public function show(Article $article, EntityManagerInterface $em): Response
{
$article->incrementViews();
$em->flush();
return $this->render('article/show.html.twig', [
'article' => $article,
]);
}
#[Route('/admin/new', name: 'article_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $em): Response
{
$article = new Article();
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($article);
$em->flush();
$this->addFlash('success', 'Article created successfully!');
return $this->redirectToRoute('article_show', ['slug' => $article->getSlug()]);
}
return $this->render('article/new.html.twig', [
'form' => $form,
]);
}
}
Article $article parameter in show() uses the "route value resolver": Symfony loads the Article via its slug before calling the method.Definition: HTML file with Twig syntax that mixes HTML and presentation logic (loops, conditions, filters).
Purpose: Separate presentation (HTML/CSS) from business logic (PHP).
Why here: Twig compiles templates to optimized PHP, offering security (auto-escaping) and reusability (inheritance, includes).
{% extends 'base.html.twig' %}
{% block title %}Blog โ Articles{% endblock %}
{% block body %}
<div class="container">
<h1>Articles</h1>
{% for article in articles %}
<article class="card">
<h2>
<a href="{{ path('article_show', {slug: article.slug}) }}">
{{ article.title }}
</a>
</h2>
<p class="meta">
Published on {{ article.publishedAt|date('d/m/Y') }}
ยท {{ article.views }} view(s)
</p>
{% if article.excerpt %}
<p>{{ article.excerpt }}</p>
{% else %}
<p>{{ article.content|slice(0, 200) }}...</p>
{% endif %}
</article>
{% else %}
<p>No published articles at the moment.</p>
{% endfor %}
</div>
{% endblock %}
Definition: PHP file describing database schema changes (CREATE TABLE, ALTER COLUMN, etc.).
Purpose: Version schema changes like code, for team collaboration and safe production deployment.
Why here: Doctrine generates migrations from entities: make:migration compares current schema to entities and generates SQL.
make:migration so version control captures changes.# Create the migration
php bin/console make:migration
# Execute the migration
php bin/console doctrine:migrations:migrate
# Load test fixtures
php bin/console doctrine:fixtures:load
# Check available routes
php bin/console debug:router