๐Ÿ˜ Symfony ยท Intermediate

Complete MVC Application with Symfony 7

โฑ 55 minutes๐Ÿ˜ PHP 8.3๐ŸŽผ Symfony 7๐Ÿ—„๏ธ Doctrine ORM

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.

๐Ÿ“– Term: MVC Pattern (Model-View-Controller)

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.

1. Create the Symfony project

Terminal
# 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

2. Configure the database

.env
# 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"

3. Create entities

๐Ÿ“– Term: Entity

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.

๐Ÿ“– Term: ORM (Object-Relational Mapping)

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.

Terminal โ€” generate entities
# 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
src/Entity/Article.php
<?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; }
}
The #[ORM\*] attributes map properties to columns. #[Assert\*] validates data before persistence. #[ORM\PrePersist] is a lifecycle callback executed before INSERT.

4. Repository with custom queries

๐Ÿ“– Term: Repository

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.

src/Repository/ArticleRepository.php
<?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();
    }
}
QueryBuilder builds type-safe SQL queries: createQueryBuilder('a') creates a query on Article aliased 'a'. Methods where(), orderBy(), getResult() correspond to SELECT, WHERE, ORDER BY, FETCH.

5. Controller

๐Ÿ“– Term: Controller

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).

๐Ÿ“– Term: Dependency Injection

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.

src/Controller/ArticleController.php
<?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,
        ]);
    }
}
Action parameters (ArticleRepository, EntityManagerInterface, Article) are automatically injected by the Symfony container. The Article $article parameter in show() uses the "route value resolver": Symfony loads the Article via its slug before calling the method.

6. Twig template

๐Ÿ“– Term: Twig Template

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).

Twig syntax: {{ variable }} displays a variable (escapes HTML by default), {% instruction %} executes logic (loops, conditions), {{ variable|filter }} applies a filter to the variable.
templates/article/index.html.twig
{% 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 %}

7. Migrations and startup

๐Ÿ“– Term: Migration

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.

NEVER modify the database directly (manual ALTER TABLE): it would desynchronize your Doctrine migrations. Always go through make:migration so version control captures changes.
Terminal
# 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
Your blog is accessible at https://127.0.0.1:8000/articles