Symfony с нуля

Создаём личный кабинет для автосервиса

Полное руководство по изучению Symfony Framework на практическом примере — разработка системы личного кабинета для клиентов автосервиса с записью на услуги, историей обслуживания и управлением автомобилями

📖

Введение в Symfony

Что такое Symfony и почему его стоит изучать

Symfony — это мощный PHP-фреймворк для создания веб-приложений и API. Он используется в тысячах проектов, включая такие платформы как Drupal, Laravel (использует компоненты Symfony), phpBB и многие другие.

🎯 Что мы создадим

В этом руководстве мы создадим полнофункциональный личный кабинет для клиентов автосервиса:

👤

Регистрация и авторизация

Безопасная система входа с подтверждением email и восстановлением пароля

🚗

Управление автомобилями

Добавление, редактирование и удаление своих автомобилей

📅

Запись на услуги

Онлайн-запись на техобслуживание, ремонт и другие услуги

📋

История обслуживания

Полная история всех работ по каждому автомобилю

💳

Счета и оплаты

Просмотр счетов и истории платежей

🔔

Уведомления

Напоминания о ТО и статусе заказов

📚 Концепции Symfony, которые изучим

Концепция Описание Применение в проекте
MVC Model-View-Controller архитектура Структура всего приложения
Doctrine ORM Объектно-реляционное отображение Работа с базой данных
Twig Шаблонизатор Все HTML-страницы
Forms Компонент форм Регистрация, добавление авто, запись
Security Система безопасности Авторизация, права доступа
Services & DI Внедрение зависимостей Бизнес-логика, сервисы
Events Система событий Уведомления, логирование
Messenger Очереди сообщений Email, асинхронные задачи
⚙️

Установка и настройка

Подготовка окружения и создание проекта

📋 Требования

  • PHP 8.1 или выше
  • Composer
  • MySQL/PostgreSQL/SQLite
  • Node.js (для работы с assets)
  • Symfony CLI (рекомендуется)

🛠️ Шаг 1: Установка Symfony CLI

💻 Terminal
Bash
# Установка Symfony CLI (macOS/Linux)
curl -sS https://get.symfony.com/cli/installer | bash

# Или через Homebrew (macOS)
brew install symfony-cli/tap/symfony-cli

# Проверка установки
symfony check:requirements

🚀 Шаг 2: Создание проекта

💻 Terminal
Bash
# Создаём новый проект Symfony
symfony new autoservice --webapp

# Переходим в директорию проекта
cd autoservice

# Или через Composer (альтернатива)
composer create-project symfony/skeleton autoservice
cd autoservice
composer require webapp

📦 Шаг 3: Установка дополнительных пакетов

💻 Terminal
Bash
# Основные пакеты (уже включены в webapp)
composer require symfony/orm-pack           # Doctrine ORM
composer require symfony/form               # Формы
composer require symfony/validator          # Валидация
composer require symfony/security-bundle    # Безопасность
composer require symfony/twig-bundle        # Шаблоны
composer require symfony/mailer             # Отправка email
composer require symfony/messenger          # Очереди

# Полезные дополнения
composer require symfonycasts/reset-password-bundle  # Сброс пароля
composer require symfonycasts/verify-email-bundle    # Подтверждение email
composer require knplabs/knp-paginator-bundle        # Пагинация
composer require stof/doctrine-extensions-bundle     # Timestampable и др.

# Инструменты разработки
composer require --dev symfony/maker-bundle          # Генераторы кода
composer require --dev symfony/debug-bundle          # Отладка
composer require --dev symfony/profiler-pack         # Профилировщик
composer require --dev doctrine/doctrine-fixtures-bundle # Тестовые данные

# Assets (CSS/JS)
composer require symfony/asset-mapper      # Без сборщиков
# ИЛИ
composer require symfony/webpack-encore-bundle # С Webpack

🔧 Шаг 4: Настройка окружения

📄 .env
ENV
# .env - Настройки окружения

# Режим приложения
APP_ENV=dev
APP_SECRET=your-secret-key-here-change-in-production

# База данных (MySQL)
DATABASE_URL="mysql://root:password@127.0.0.1:3306/autoservice?serverVersion=8.0&charset=utf8mb4"

# Или PostgreSQL
# DATABASE_URL="postgresql://user:password@127.0.0.1:5432/autoservice?serverVersion=15&charset=utf8"

# Или SQLite для разработки
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"

# Настройки email (для разработки используем Mailtrap)
MAILER_DSN=smtp://user:pass@smtp.mailtrap.io:2525

# Messenger (для очередей)
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0

▶️ Шаг 5: Запуск сервера

💻 Terminal
Bash
# Запуск встроенного сервера Symfony
symfony server:start

# Или в фоновом режиме
symfony server:start -d

# Откройте в браузере: https://127.0.0.1:8000
💡 Совет

Symfony CLI автоматически настраивает HTTPS для локальной разработки. Если нужен HTTP, используйте symfony server:start --no-tls

📁

Структура проекта

Организация файлов и директорий Symfony

autoservice/ ├── 📁 bin/ │ └── console # CLI команды Symfony │ ├── 📁 config/ # Конфигурация │ ├── 📁 packages/ # Конфиги пакетов │ │ ├── doctrine.yaml │ │ ├── security.yaml │ │ ├── twig.yaml │ │ └── ... │ ├── 📁 routes/ # Маршруты │ ├── bundles.php │ ├── routes.yaml │ └── services.yaml # Сервисы приложения │ ├── 📁 migrations/ # Миграции БД │ ├── 📁 public/ # Публичная директория (точка входа) │ ├── index.php # Front controller │ └── 📁 assets/ # Публичные ресурсы │ ├── 📁 src/ # Исходный код приложения │ ├── 📁 Controller/ # Контроллеры │ │ ├── DashboardController.php │ │ ├── CarController.php │ │ ├── AppointmentController.php │ │ └── SecurityController.php │ │ │ ├── 📁 Entity/ # Сущности Doctrine │ │ ├── User.php │ │ ├── Car.php │ │ ├── Service.php │ │ ├── Appointment.php │ │ ├── ServiceHistory.php │ │ └── Invoice.php │ │ │ ├── 📁 Repository/ # Репозитории │ │ ├── UserRepository.php │ │ ├── CarRepository.php │ │ └── ... │ │ │ ├── 📁 Form/ # Формы │ │ ├── RegistrationFormType.php │ │ ├── CarType.php │ │ └── AppointmentType.php │ │ │ ├── 📁 Service/ # Сервисы бизнес-логики │ │ ├── AppointmentService.php │ │ ├── NotificationService.php │ │ └── InvoiceService.php │ │ │ ├── 📁 EventSubscriber/ # Подписчики событий │ │ │ ├── 📁 Message/ # Сообщения Messenger │ │ │ ├── 📁 MessageHandler/ # Обработчики сообщений │ │ │ ├── 📁 Security/ # Безопасность │ │ └── Voter/ │ │ │ └── Kernel.php │ ├── 📁 templates/ # Twig шаблоны │ ├── base.html.twig # Базовый шаблон │ ├── 📁 dashboard/ │ ├── 📁 car/ │ ├── 📁 appointment/ │ ├── 📁 security/ │ └── 📁 components/ │ ├── 📁 tests/ # Тесты │ ├── 📁 translations/ # Переводы │ ├── 📁 var/ # Кэш и логи │ ├── cache/ │ └── log/ │ ├── 📁 vendor/ # Зависимости Composer │ ├── .env # Переменные окружения ├── composer.json └── symfony.lock
🗄️

База данных

Doctrine ORM и работа с данными

📊 Схема базы данных

┌─────────────────────────────────────────────────────────────────────────────────────────┐
│                           DATABASE SCHEMA: AutoService                                  │
└─────────────────────────────────────────────────────────────────────────────────────────┘

┌──────────────────┐       ┌──────────────────┐       ┌──────────────────┐
│      USER        │       │       CAR        │       │     SERVICE      │
├──────────────────┤       ├──────────────────┤       ├──────────────────┤
│ id           PK  │       │ id           PK  │       │ id           PK  │
│ email            │       │ user_id      FK  │───┐   │ name             │
│ password         │       │ brand            │   │   │ description      │
│ roles            │       │ model            │   │   │ price            │
│ first_name       │       │ year             │   │   │ duration_minutes │
│ last_name        │       │ license_plate    │   │   │ category         │
│ phone            │       │ vin              │   │   │ is_active        │
│ is_verified      │       │ mileage          │   │   │ created_at       │
│ created_at       │       │ created_at       │   │   └──────────────────┘
│ updated_at       │       │ updated_at       │   │           │
└────────┬─────────┘       └──────────────────┘   │           │
         │                          │              │           │
         │ 1:N                      │              │           │
         │                          │              │           │
         ▼                          │              │           │
┌──────────────────┐               │              │           │
│   APPOINTMENT    │               │              │           │
├──────────────────┤               │              │           │
│ id           PK  │               │              │           │
│ user_id      FK  │◄──────────────┘              │           │
│ car_id       FK  │◄─────────────────────────────┘           │
│ scheduled_at     │                                          │
│ status           │   ┌──────────────────────────────────────┘
│ notes            │   │
│ total_price      │   │
│ created_at       │   │
│ updated_at       │   │
└────────┬─────────┘   │
         │             │
         │ M:N         │
         ▼             │
┌─────────────────────────────┐
│   APPOINTMENT_SERVICE       │
├─────────────────────────────┤
│ appointment_id  FK          │
│ service_id      FK          │◄──────────────────────────────┘
└─────────────────────────────┘

┌──────────────────┐       ┌──────────────────┐
│ SERVICE_HISTORY  │       │     INVOICE      │
├──────────────────┤       ├──────────────────┤
│ id           PK  │       │ id           PK  │
│ car_id       FK  │       │ user_id      FK  │
│ service_id   FK  │       │ appointment_id   │
│ appointment_id   │       │ number           │
│ performed_at     │       │ amount           │
│ mileage_at       │       │ status           │
│ notes            │       │ paid_at          │
│ technician       │       │ due_date         │
│ cost             │       │ created_at       │
└──────────────────┘       └──────────────────┘

═══════════════════════════════════════════════════════════════════════════════════════

СТАТУСЫ:
  Appointment: pending → confirmed → in_progress → completed → cancelled
  Invoice:     pending → paid → overdue → cancelled
        

🛠️ Создание базы данных

💻 Terminal
Bash
# Создание базы данных
php bin/console doctrine:database:create

# После создания сущностей - генерация миграции
php bin/console make:migration

# Применение миграций
php bin/console doctrine:migrations:migrate

# Полезные команды
php bin/console doctrine:schema:validate    # Проверка схемы
php bin/console doctrine:schema:update --dump-sql  # Показать SQL
📦

Сущности (Entities)

Модели данных Doctrine ORM

👤 User — Пользователь

📄 src/Entity/User.php
PHP
<?php

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['email'], message: 'Этот email уже зарегистрирован')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 180, unique: true)]
    #[Assert\NotBlank(message: 'Email обязателен')]
    #[Assert\Email(message: 'Некорректный формат email')]
    private ?string $email = null;

    #[ORM\Column]
    private array $roles = [];

    #[ORM\Column]
    private ?string $password = null;

    #[ORM\Column(length: 100)]
    #[Assert\NotBlank(message: 'Укажите имя')]
    #[Assert\Length(min: 2, max: 100)]
    private ?string $firstName = null;

    #[ORM\Column(length: 100)]
    #[Assert\NotBlank(message: 'Укажите фамилию')]
    #[Assert\Length(min: 2, max: 100)]
    private ?string $lastName = null;

    #[ORM\Column(length: 20, nullable: true)]
    #[Assert\Regex(pattern: '/^\+?[0-9]{10,15}$/', message: 'Некорректный номер телефона')]
    private ?string $phone = null;

    #[ORM\Column(type: 'boolean')]
    private bool $isVerified = false;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
    private ?\DateTime $updatedAt = null;

    /** @var Collection<int, Car> */
    #[ORM\OneToMany(targetEntity: Car::class, mappedBy: 'owner', orphanRemoval: true)]
    private Collection $cars;

    /** @var Collection<int, Appointment> */
    #[ORM\OneToMany(targetEntity: Appointment::class, mappedBy: 'user', orphanRemoval: true)]
    private Collection $appointments;

    public function __construct()
    {
        $this->cars = new ArrayCollection();
        $this->appointments = new ArrayCollection();
        $this->createdAt = new \DateTimeImmutable();
    }

    #[ORM\PreUpdate]
    public function setUpdatedAtValue(): void
    {
        $this->updatedAt = new \DateTime();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function setEmail(string $email): static
    {
        $this->email = $email;
        return $this;
    }

    /**
     * Идентификатор для авторизации (UserInterface)
     */
    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }

    /**
     * @see UserInterface
     */
    public function getRoles(): array
    {
        $roles = $this->roles;
        // Гарантируем, что у каждого пользователя есть ROLE_USER
        $roles[] = 'ROLE_USER';

        return array_unique($roles);
    }

    public function setRoles(array $roles): static
    {
        $this->roles = $roles;
        return $this;
    }

    /**
     * @see PasswordAuthenticatedUserInterface
     */
    public function getPassword(): string
    {
        return $this->password;
    }

    public function setPassword(string $password): static
    {
        $this->password = $password;
        return $this;
    }

    /**
     * @see UserInterface
     */
    public function eraseCredentials(): void
    {
        // Очистка временных данных (например, plainPassword)
    }

    public function getFirstName(): ?string
    {
        return $this->firstName;
    }

    public function setFirstName(string $firstName): static
    {
        $this->firstName = $firstName;
        return $this;
    }

    public function getLastName(): ?string
    {
        return $this->lastName;
    }

    public function setLastName(string $lastName): static
    {
        $this->lastName = $lastName;
        return $this;
    }

    public function getFullName(): string
    {
        return trim($this->firstName . ' ' . $this->lastName);
    }

    public function getPhone(): ?string
    {
        return $this->phone;
    }

    public function setPhone(?string $phone): static
    {
        $this->phone = $phone;
        return $this;
    }

    public function isVerified(): bool
    {
        return $this->isVerified;
    }

    public function setIsVerified(bool $isVerified): static
    {
        $this->isVerified = $isVerified;
        return $this;
    }

    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function getUpdatedAt(): ?\DateTime
    {
        return $this->updatedAt;
    }

    /**
     * @return Collection<int, Car>
     */
    public function getCars(): Collection
    {
        return $this->cars;
    }

    public function addCar(Car $car): static
    {
        if (!$this->cars->contains($car)) {
            $this->cars->add($car);
            $car->setOwner($this);
        }
        return $this;
    }

    public function removeCar(Car $car): static
    {
        if ($this->cars->removeElement($car)) {
            if ($car->getOwner() === $this) {
                $car->setOwner(null);
            }
        }
        return $this;
    }

    /**
     * @return Collection<int, Appointment>
     */
    public function getAppointments(): Collection
    {
        return $this->appointments;
    }
}

🚗 Car — Автомобиль

📄 src/Entity/Car.php
PHP
<?php

namespace App\Entity;

use App\Repository\CarRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: CarRepository::class)]
#[ORM\HasLifecycleCallbacks]
class Car
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'cars')]
    #[ORM\JoinColumn(nullable: false)]
    private ?User $owner = null;

    #[ORM\Column(length: 100)]
    #[Assert\NotBlank(message: 'Укажите марку автомобиля')]
    private ?string $brand = null;

    #[ORM\Column(length: 100)]
    #[Assert\NotBlank(message: 'Укажите модель автомобиля')]
    private ?string $model = null;

    #[ORM\Column(type: Types::SMALLINT)]
    #[Assert\NotBlank(message: 'Укажите год выпуска')]
    #[Assert\Range(min: 1900, max: 2030, notInRangeMessage: 'Год должен быть от {{ min }} до {{ max }}')]
    private ?int $year = null;

    #[ORM\Column(length: 20)]
    #[Assert\NotBlank(message: 'Укажите госномер')]
    #[Assert\Regex(pattern: '/^[АВЕКМНОРСТУХ]\d{3}[АВЕКМНОРСТУХ]{2}\d{2,3}$/ui', message: 'Некорректный формат госномера')]
    private ?string $licensePlate = null;

    #[ORM\Column(length: 17, nullable: true)]
    #[Assert\Length(exactly: 17, exactMessage: 'VIN должен содержать ровно 17 символов')]
    private ?string $vin = null;

    #[ORM\Column(nullable: true)]
    #[Assert\PositiveOrZero(message: 'Пробег не может быть отрицательным')]
    private ?int $mileage = null;

    #[ORM\Column(length: 50, nullable: true)]
    private ?string $color = null;

    #[ORM\Column(length: 50, nullable: true)]
    private ?string $engineType = null; // бензин, дизель, электро, гибрид

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
    private ?\DateTime $updatedAt = null;

    /** @var Collection<int, Appointment> */
    #[ORM\OneToMany(targetEntity: Appointment::class, mappedBy: 'car')]
    private Collection $appointments;

    /** @var Collection<int, ServiceHistory> */
    #[ORM\OneToMany(targetEntity: ServiceHistory::class, mappedBy: 'car', orphanRemoval: true)]
    private Collection $serviceHistory;

    public function __construct()
    {
        $this->appointments = new ArrayCollection();
        $this->serviceHistory = new ArrayCollection();
        $this->createdAt = new \DateTimeImmutable();
    }

    #[ORM\PreUpdate]
    public function setUpdatedAtValue(): void
    {
        $this->updatedAt = new \DateTime();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getOwner(): ?User
    {
        return $this->owner;
    }

    public function setOwner(?User $owner): static
    {
        $this->owner = $owner;
        return $this;
    }

    public function getBrand(): ?string
    {
        return $this->brand;
    }

    public function setBrand(string $brand): static
    {
        $this->brand = $brand;
        return $this;
    }

    public function getModel(): ?string
    {
        return $this->model;
    }

    public function setModel(string $model): static
    {
        $this->model = $model;
        return $this;
    }

    public function getYear(): ?int
    {
        return $this->year;
    }

    public function setYear(int $year): static
    {
        $this->year = $year;
        return $this;
    }

    public function getLicensePlate(): ?string
    {
        return $this->licensePlate;
    }

    public function setLicensePlate(string $licensePlate): static
    {
        $this->licensePlate = mb_strtoupper($licensePlate);
        return $this;
    }

    public function getVin(): ?string
    {
        return $this->vin;
    }

    public function setVin(?string $vin): static
    {
        $this->vin = $vin ? strtoupper($vin) : null;
        return $this;
    }

    public function getMileage(): ?int
    {
        return $this->mileage;
    }

    public function setMileage(?int $mileage): static
    {
        $this->mileage = $mileage;
        return $this;
    }

    public function getColor(): ?string
    {
        return $this->color;
    }

    public function setColor(?string $color): static
    {
        $this->color = $color;
        return $this;
    }

    public function getEngineType(): ?string
    {
        return $this->engineType;
    }

    public function setEngineType(?string $engineType): static
    {
        $this->engineType = $engineType;
        return $this;
    }

    /**
     * Полное название автомобиля
     */
    public function getFullName(): string
    {
        return sprintf('%s %s (%d)', $this->brand, $this->model, $this->year);
    }

    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }

    /**
     * @return Collection<int, Appointment>
     */
    public function getAppointments(): Collection
    {
        return $this->appointments;
    }

    /**
     * @return Collection<int, ServiceHistory>
     */
    public function getServiceHistory(): Collection
    {
        return $this->serviceHistory;
    }

    public function __toString(): string
    {
        return $this->getFullName() . ' - ' . $this->licensePlate;
    }
}

🔧 Service — Услуга

📄 src/Entity/Service.php
PHP
<?php

namespace App\Entity;

use App\Repository\ServiceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: ServiceRepository::class)]
class Service
{
    // Категории услуг
    public const CATEGORY_MAINTENANCE = 'maintenance';    // ТО
    public const CATEGORY_REPAIR = 'repair';              // Ремонт
    public const CATEGORY_DIAGNOSTICS = 'diagnostics';    // Диагностика
    public const CATEGORY_TIRE = 'tire';                  // Шиномонтаж
    public const CATEGORY_BODY = 'body';                  // Кузовные работы
    public const CATEGORY_DETAILING = 'detailing';        // Детейлинг

    public const CATEGORIES = [
        self::CATEGORY_MAINTENANCE => 'Техническое обслуживание',
        self::CATEGORY_REPAIR => 'Ремонт',
        self::CATEGORY_DIAGNOSTICS => 'Диагностика',
        self::CATEGORY_TIRE => 'Шиномонтаж',
        self::CATEGORY_BODY => 'Кузовные работы',
        self::CATEGORY_DETAILING => 'Детейлинг',
    ];

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    private ?string $name = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $description = null;

    #[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2)]
    #[Assert\NotBlank]
    #[Assert\Positive]
    private ?string $price = null;

    #[ORM\Column(type: Types::SMALLINT)]
    #[Assert\Positive]
    private ?int $durationMinutes = 60;

    #[ORM\Column(length: 50)]
    private ?string $category = self::CATEGORY_MAINTENANCE;

    #[ORM\Column(type: 'boolean')]
    private bool $isActive = true;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private ?\DateTimeImmutable $createdAt = null;

    /** @var Collection<int, Appointment> */
    #[ORM\ManyToMany(targetEntity: Appointment::class, mappedBy: 'services')]
    private Collection $appointments;

    public function __construct()
    {
        $this->appointments = new ArrayCollection();
        $this->createdAt = new \DateTimeImmutable();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;
        return $this;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(?string $description): static
    {
        $this->description = $description;
        return $this;
    }

    public function getPrice(): ?string
    {
        return $this->price;
    }

    public function getPriceAsFloat(): float
    {
        return (float) $this->price;
    }

    public function setPrice(string $price): static
    {
        $this->price = $price;
        return $this;
    }

    public function getDurationMinutes(): ?int
    {
        return $this->durationMinutes;
    }

    public function setDurationMinutes(int $durationMinutes): static
    {
        $this->durationMinutes = $durationMinutes;
        return $this;
    }

    /**
     * Получить длительность в формате "X ч Y мин"
     */
    public function getDurationFormatted(): string
    {
        $hours = intdiv($this->durationMinutes, 60);
        $minutes = $this->durationMinutes % 60;

        if ($hours > 0 && $minutes > 0) {
            return sprintf('%d ч %d мин', $hours, $minutes);
        } elseif ($hours > 0) {
            return sprintf('%d ч', $hours);
        }
        return sprintf('%d мин', $minutes);
    }

    public function getCategory(): ?string
    {
        return $this->category;
    }

    public function getCategoryName(): string
    {
        return self::CATEGORIES[$this->category] ?? $this->category;
    }

    public function setCategory(string $category): static
    {
        $this->category = $category;
        return $this;
    }

    public function isActive(): bool
    {
        return $this->isActive;
    }

    public function setIsActive(bool $isActive): static
    {
        $this->isActive = $isActive;
        return $this;
    }

    public function __toString(): string
    {
        return $this->name ?? '';
    }
}

📅 Appointment — Запись на услугу

📄 src/Entity/Appointment.php
PHP
<?php

namespace App\Entity;

use App\Repository\AppointmentRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: AppointmentRepository::class)]
#[ORM\HasLifecycleCallbacks]
class Appointment
{
    // Статусы записи
    public const STATUS_PENDING = 'pending';          // Ожидает подтверждения
    public const STATUS_CONFIRMED = 'confirmed';      // Подтверждена
    public const STATUS_IN_PROGRESS = 'in_progress';  // В работе
    public const STATUS_COMPLETED = 'completed';      // Выполнена
    public const STATUS_CANCELLED = 'cancelled';      // Отменена

    public const STATUSES = [
        self::STATUS_PENDING => 'Ожидает подтверждения',
        self::STATUS_CONFIRMED => 'Подтверждена',
        self::STATUS_IN_PROGRESS => 'В работе',
        self::STATUS_COMPLETED => 'Выполнена',
        self::STATUS_CANCELLED => 'Отменена',
    ];

    public const STATUS_COLORS = [
        self::STATUS_PENDING => 'warning',
        self::STATUS_CONFIRMED => 'info',
        self::STATUS_IN_PROGRESS => 'primary',
        self::STATUS_COMPLETED => 'success',
        self::STATUS_CANCELLED => 'danger',
    ];

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'appointments')]
    #[ORM\JoinColumn(nullable: false)]
    private ?User $user = null;

    #[ORM\ManyToOne(targetEntity: Car::class, inversedBy: 'appointments')]
    #[ORM\JoinColumn(nullable: false)]
    #[Assert\NotNull(message: 'Выберите автомобиль')]
    private ?Car $car = null;

    /** @var Collection<int, Service> */
    #[ORM\ManyToMany(targetEntity: Service::class, inversedBy: 'appointments')]
    #[Assert\Count(min: 1, minMessage: 'Выберите хотя бы одну услугу')]
    private Collection $services;

    #[ORM\Column(type: Types::DATETIME_MUTABLE)]
    #[Assert\NotNull(message: 'Выберите дату и время')]
    #[Assert\GreaterThan('today', message: 'Дата должна быть в будущем')]
    private ?\DateTime $scheduledAt = null;

    #[ORM\Column(length: 20)]
    private string $status = self::STATUS_PENDING;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $notes = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $adminNotes = null; // Заметки администратора

    #[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
    private ?string $totalPrice = null;

    #[ORM\Column(nullable: true)]
    private ?int $estimatedDuration = null; // Общее время в минутах

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
    private ?\DateTime $updatedAt = null;

    public function __construct()
    {
        $this->services = new ArrayCollection();
        $this->createdAt = new \DateTimeImmutable();
    }

    #[ORM\PrePersist]
    #[ORM\PreUpdate]
    public function calculateTotals(): void
    {
        $totalPrice = 0;
        $totalDuration = 0;

        foreach ($this->services as $service) {
            $totalPrice += $service->getPriceAsFloat();
            $totalDuration += $service->getDurationMinutes();
        }

        $this->totalPrice = (string) $totalPrice;
        $this->estimatedDuration = $totalDuration;
    }

    #[ORM\PreUpdate]
    public function setUpdatedAtValue(): void
    {
        $this->updatedAt = new \DateTime();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUser(): ?User
    {
        return $this->user;
    }

    public function setUser(?User $user): static
    {
        $this->user = $user;
        return $this;
    }

    public function getCar(): ?Car
    {
        return $this->car;
    }

    public function setCar(?Car $car): static
    {
        $this->car = $car;
        return $this;
    }

    /**
     * @return Collection<int, Service>
     */
    public function getServices(): Collection
    {
        return $this->services;
    }

    public function addService(Service $service): static
    {
        if (!$this->services->contains($service)) {
            $this->services->add($service);
        }
        return $this;
    }

    public function removeService(Service $service): static
    {
        $this->services->removeElement($service);
        return $this;
    }

    public function getScheduledAt(): ?\DateTime
    {
        return $this->scheduledAt;
    }

    public function setScheduledAt(?\DateTime $scheduledAt): static
    {
        $this->scheduledAt = $scheduledAt;
        return $this;
    }

    public function getStatus(): string
    {
        return $this->status;
    }

    public function getStatusName(): string
    {
        return self::STATUSES[$this->status] ?? $this->status;
    }

    public function getStatusColor(): string
    {
        return self::STATUS_COLORS[$this->status] ?? 'secondary';
    }

    public function setStatus(string $status): static
    {
        $this->status = $status;
        return $this;
    }

    public function getNotes(): ?string
    {
        return $this->notes;
    }

    public function setNotes(?string $notes): static
    {
        $this->notes = $notes;
        return $this;
    }

    public function getTotalPrice(): ?string
    {
        return $this->totalPrice;
    }

    public function getEstimatedDuration(): ?int
    {
        return $this->estimatedDuration;}

public function getEstimatedDurationFormatted(): string
{
    if (!$this->estimatedDuration) {
        return '—';
    }

    $hours = intdiv($this->estimatedDuration, 60);
    $minutes = $this->estimatedDuration % 60;

    if ($hours > 0 && $minutes > 0) {
        return sprintf('%d ч %d мин', $hours, $minutes);
    } elseif ($hours > 0) {
        return sprintf('%d ч', $hours);
    }
    return sprintf('%d мин', $minutes);
}

public function getCreatedAt(): ?\DateTimeImmutable
{
    return $this->createdAt;
}

/**
 * Можно ли отменить запись
 */
public function isCancellable(): bool
{
    return in_array($this->status, [
        self::STATUS_PENDING,
        self::STATUS_CONFIRMED,
    ]);
}

/**
 * Можно ли редактировать запись
 */
public function isEditable(): bool
{
    return $this->status === self::STATUS_PENDING;
}
}
💡 Генерация сущностей

Symfony предоставляет удобную команду для генерации сущностей:

php bin/console make:entity

Команда интерактивно проведёт вас через создание полей и связей.

🔐

Авторизация и безопасность

Настройка Security Bundle

⚙️ Конфигурация безопасности

📄 config/packages/security.yaml
YAML
security:
# Алгоритм хэширования паролей
password_hashers:
    Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

# Провайдеры пользователей
providers:
    app_user_provider:
        entity:
            class: App\Entity\User
            property: email

# Файрволы
firewalls:
    dev:
        pattern: ^/(_(profiler|wdt)|css|images|js)/
        security: false

    main:
        lazy: true
        provider: app_user_provider

        # Форма входа
        form_login:
            login_path: app_login
            check_path: app_login
            default_target_path: app_dashboard
            enable_csrf: true

        # Выход из системы
        logout:
            path: app_logout
            target: app_home

        # Запомнить меня
        remember_me:
            secret: '%kernel.secret%'
            lifetime: 604800 # 1 неделя
            path: /

# Контроль доступа
access_control:
    # API маршруты
    - { path: ^/api/login, roles: PUBLIC_ACCESS }
    - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

    # Админка
    - { path: ^/admin, roles: ROLE_ADMIN }

    # Личный кабинет
    - { path: ^/dashboard, roles: ROLE_USER }
    - { path: ^/cars, roles: ROLE_USER }
    - { path: ^/appointments, roles: ROLE_USER }
    - { path: ^/profile, roles: ROLE_USER }

# Иерархия ролей
role_hierarchy:
    ROLE_ADMIN: [ROLE_USER]
    ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

🔑 SecurityController — Вход и регистрация

📄 src/Controller/SecurityController.php
PHP
<?php

namespace App\Controller;

use App\Entity\User;
use App\Form\RegistrationFormType;
use App\Security\EmailVerifier;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mime\Address;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;

class SecurityController extends AbstractController
{
public function __construct(
    private EmailVerifier $emailVerifier
) {}

/**
 * Страница входа
 */
#[Route('/login', name: 'app_login')]
public function login(AuthenticationUtils $authUtils): Response
{
    // Если уже авторизован — редирект в кабинет
    if ($this->getUser()) {
        return $this->redirectToRoute('app_dashboard');
    }

    // Получаем ошибку входа, если есть
    $error = $authUtils->getLastAuthenticationError();
    // Последний введённый email
    $lastUsername = $authUtils->getLastUsername();

    return $this->render('security/login.html.twig', [
        'last_username' => $lastUsername,
        'error' => $error,
    ]);
}

/**
 * Выход из системы
 */
#[Route('/logout', name: 'app_logout')]
public function logout(): void
{
    // Этот метод может быть пустым — перехватывается файрволом
    throw new \LogicException('This should never be reached!');
}

/**
 * Регистрация
 */
#[Route('/register', name: 'app_register')]
public function register(
    Request $request,
    UserPasswordHasherInterface $passwordHasher,
    EntityManagerInterface $entityManager
): Response {
    if ($this->getUser()) {
        return $this->redirectToRoute('app_dashboard');
    }

    $user = new User();
    $form = $this->createForm(RegistrationFormType::class, $user);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        // Хэшируем пароль
        $user->setPassword(
            $passwordHasher->hashPassword(
                $user,
                $form->get('plainPassword')->getData()
            )
        );

        $entityManager->persist($user);
        $entityManager->flush();

        // Отправляем email для подтверждения
        $this->emailVerifier->sendEmailConfirmation(
            'app_verify_email',
            $user,
            (new TemplatedEmail())
                ->from(new Address('noreply@autoservice.ru', 'АвтоСервис'))
                ->to($user->getEmail())
                ->subject('Подтвердите ваш email')
                ->htmlTemplate('email/confirmation_email.html.twig')
        );

        $this->addFlash('success', 'Регистрация успешна! Проверьте почту для подтверждения.');

        return $this->redirectToRoute('app_login');
    }

    return $this->render('security/register.html.twig', [
        'registrationForm' => $form,
    ]);
}

/**
 * Подтверждение email
 */
#[Route('/verify/email', name: 'app_verify_email')]
public function verifyUserEmail(Request $request): Response
{
    $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');

    try {
        $this->emailVerifier->handleEmailConfirmation($request, $this->getUser());
    } catch (VerifyEmailExceptionInterface $exception) {
        $this->addFlash('error', $exception->getReason());
        return $this->redirectToRoute('app_register');
    }

    $this->addFlash('success', 'Email успешно подтверждён!');
    return $this->redirectToRoute('app_dashboard');
}
}

📝 Форма регистрации

📄 src/Form/RegistrationFormType.php
PHP
<?php

namespace App\Form;

use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;

class RegistrationFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
    $builder
        ->add('firstName', TextType::class, [
            'label' => 'Имя',
            'attr' => [
                'placeholder' => 'Введите ваше имя',
                'class' => 'form-control',
            ],
        ])
        ->add('lastName', TextType::class, [
            'label' => 'Фамилия',
            'attr' => [
                'placeholder' => 'Введите вашу фамилию',
                'class' => 'form-control',
            ],
        ])
        ->add('email', EmailType::class, [
            'label' => 'Email',
            'attr' => [
                'placeholder' => 'example@mail.ru',
                'class' => 'form-control',
            ],
        ])
        ->add('phone', TelType::class, [
            'label' => 'Телефон',
            'required' => false,
            'attr' => [
                'placeholder' => '+7 (999) 123-45-67',
                'class' => 'form-control',
            ],
        ])
        ->add('plainPassword', RepeatedType::class, [
            'type' => PasswordType::class,
            'mapped' => false,
            'first_options' => [
                'label' => 'Пароль',
                'attr' => [
                    'placeholder' => 'Минимум 6 символов',
                    'class' => 'form-control',
                    'autocomplete' => 'new-password',
                ],
            ],
            'second_options' => [
                'label' => 'Подтвердите пароль',
                'attr' => [
                    'placeholder' => 'Повторите пароль',
                    'class' => 'form-control',
                ],
            ],
            'invalid_message' => 'Пароли не совпадают',
            'constraints' => [
                new NotBlank(['message' => 'Введите пароль']),
                new Length([
                    'min' => 6,
                    'minMessage' => 'Пароль должен содержать минимум {{ limit }} символов',
                    'max' => 4096,
                ]),
            ],
        ])
        ->add('agreeTerms', CheckboxType::class, [
            'label' => 'Я согласен с условиями использования',
            'mapped' => false,
            'constraints' => [
                new IsTrue(['message' => 'Необходимо принять условия']),
            ],
        ]);
}

public function configureOptions(OptionsResolver $resolver): void
{
    $resolver->setDefaults([
        'data_class' => User::class,
    ]);
}
}
🎮

Контроллеры

Обработка HTTP-запросов

📊 DashboardController — Главная страница кабинета

📄 src/Controller/DashboardController.php
PHP
<?php

namespace App\Controller;

use App\Entity\Appointment;
use App\Repository\AppointmentRepository;
use App\Repository\CarRepository;
use App\Repository\InvoiceRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

#[Route('/dashboard')]
class DashboardController extends AbstractController
{
public function __construct(
    private CarRepository $carRepository,
    private AppointmentRepository $appointmentRepository,
    private InvoiceRepository $invoiceRepository,
) {}

#[Route('', name: 'app_dashboard')]
public function index(): Response
{
    $user = $this->getUser();

    // Статистика пользователя
    $stats = [
        'carsCount' => $this->carRepository->countByUser($user),
        'appointmentsCount' => $this->appointmentRepository->countByUser($user),
        'pendingCount' => $this->appointmentRepository->countByUserAndStatus(
            $user,
            [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED]
        ),
        'unpaidInvoices' => $this->invoiceRepository->countUnpaidByUser($user),
    ];

    // Ближайшие записи
    $upcomingAppointments = $this->appointmentRepository
        ->findUpcomingByUser($user, 5);

    // Последние автомобили
    $cars = $this->carRepository->findByUser($user, 3);

    return $this->render('dashboard/index.html.twig', [
        'stats' => $stats,
        'upcomingAppointments' => $upcomingAppointments,
        'cars' => $cars,
    ]);
}
}

🚗 CarController — Управление автомобилями

📄 src/Controller/CarController.php
PHP
<?php

namespace App\Controller;

use App\Entity\Car;
use App\Form\CarType;
use App\Repository\CarRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/cars')]
#[IsGranted('ROLE_USER')]
class CarController extends AbstractController
{
public function __construct(
    private EntityManagerInterface $entityManager,
    private CarRepository $carRepository,
) {}

/**
 * Список автомобилей пользователя
 */
#[Route('', name: 'app_cars')]
public function index(): Response
{
    $cars = $this->carRepository->findBy(
        ['owner' => $this->getUser()],
        ['createdAt' => 'DESC']
    );

    return $this->render('car/index.html.twig', [
        'cars' => $cars,
    ]);
}

/**
 * Добавление нового автомобиля
 */
#[Route('/new', name: 'app_car_new')]
public function new(Request $request): Response
{
    $car = new Car();
    $car->setOwner($this->getUser());

    $form = $this->createForm(CarType::class, $car);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $this->entityManager->persist($car);
        $this->entityManager->flush();

        $this->addFlash('success', 'Автомобиль успешно добавлен!');

        return $this->redirectToRoute('app_cars');
    }

    return $this->render('car/new.html.twig', [
        'form' => $form,
    ]);
}

/**
 * Просмотр автомобиля
 */
#[Route('/{id}', name: 'app_car_show', requirements: ['id' => '\d+'])]
public function show(Car $car): Response
{
    // Проверяем, что автомобиль принадлежит текущему пользователю
    $this->denyAccessUnlessGranted('CAR_VIEW', $car);

    return $this->render('car/show.html.twig', [
        'car' => $car,
    ]);
}

/**
 * Редактирование автомобиля
 */
#[Route('/{id}/edit', name: 'app_car_edit', requirements: ['id' => '\d+'])]
public function edit(Request $request, Car $car): Response
{
    $this->denyAccessUnlessGranted('CAR_EDIT', $car);

    $form = $this->createForm(CarType::class, $car);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $this->entityManager->flush();

        $this->addFlash('success', 'Данные автомобиля обновлены!');

        return $this->redirectToRoute('app_car_show', ['id' => $car->getId()]);
    }

    return $this->render('car/edit.html.twig', [
        'car' => $car,
        'form' => $form,
    ]);
}

/**
 * Удаление автомобиля
 */
#[Route('/{id}/delete', name: 'app_car_delete', methods: ['POST'])]
public function delete(Request $request, Car $car): Response
{
    $this->denyAccessUnlessGranted('CAR_DELETE', $car);

    // Проверяем CSRF токен
    if ($this->isCsrfTokenValid('delete' . $car->getId(), $request->request->get('_token'))) {
        $this->entityManager->remove($car);
        $this->entityManager->flush();

        $this->addFlash('success', 'Автомобиль удалён');
    }

    return $this->redirectToRoute('app_cars');
}
}

📅 AppointmentController — Записи на услуги

📄 src/Controller/AppointmentController.php
PHP
<?php

namespace App\Controller;

use App\Entity\Appointment;
use App\Form\AppointmentType;
use App\Repository\AppointmentRepository;
use App\Repository\ServiceRepository;
use App\Service\AppointmentService;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/appointments')]
#[IsGranted('ROLE_USER')]
class AppointmentController extends AbstractController
{
public function __construct(
    private EntityManagerInterface $entityManager,
    private AppointmentRepository $appointmentRepository,
    private AppointmentService $appointmentService,
) {}

/**
 * Список записей пользователя
 */
#[Route('', name: 'app_appointments')]
public function index(
    Request $request,
    PaginatorInterface $paginator
): Response {
    $status = $request->query->get('status');

    $queryBuilder = $this->appointmentRepository
        ->createQueryBuilderForUser($this->getUser(), $status);

    $pagination = $paginator->paginate(
        $queryBuilder,
        $request->query->getInt('page', 1),
        10
    );

    return $this->render('appointment/index.html.twig', [
        'pagination' => $pagination,
        'currentStatus' => $status,
        'statuses' => Appointment::STATUSES,
    ]);
}

/**
 * Создание новой записи
 */
#[Route('/new', name: 'app_appointment_new')]
public function new(
    Request $request,
    ServiceRepository $serviceRepository
): Response {
    $user = $this->getUser();

    // Проверяем, есть ли у пользователя автомобили
    if ($user->getCars()->isEmpty()) {
        $this->addFlash('warning', 'Сначала добавьте автомобиль');
        return $this->redirectToRoute('app_car_new');
    }

    $appointment = new Appointment();
    $appointment->setUser($user);

    $form = $this->createForm(AppointmentType::class, $appointment, [
        'user' => $user,
    ]);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        // Проверяем доступность времени
        if (!$this->appointmentService->isTimeSlotAvailable($appointment)) {
            $this->addFlash('error', 'Выбранное время уже занято. Пожалуйста, выберите другое.');
            return $this->render('appointment/new.html.twig', [
                'form' => $form,
            ]);
        }

        $this->entityManager->persist($appointment);
        $this->entityManager->flush();

        // Отправляем уведомление
        $this->appointmentService->sendConfirmationEmail($appointment);

        $this->addFlash('success', 'Запись создана! Ожидайте подтверждения.');

        return $this->redirectToRoute('app_appointment_show', [
            'id' => $appointment->getId()
        ]);
    }

    // Получаем услуги для отображения
    $services = $serviceRepository->findAllActiveGroupedByCategory();

    return $this->render('appointment/new.html.twig', [
        'form' => $form,
        'services' => $services,
    ]);
}

/**
 * Просмотр записи
 */
#[Route('/{id}', name: 'app_appointment_show', requirements: ['id' => '\d+'])]
public function show(Appointment $appointment): Response
{
    $this->denyAccessUnlessGranted('APPOINTMENT_VIEW', $appointment);

    return $this->render('appointment/show.html.twig', [
        'appointment' => $appointment,
    ]);
}

/**
 * Отмена записи
 */
#[Route('/{id}/cancel', name: 'app_appointment_cancel', methods: ['POST'])]
public function cancel(Request $request, Appointment $appointment): Response
{
    $this->denyAccessUnlessGranted('APPOINTMENT_CANCEL', $appointment);

    if ($this->isCsrfTokenValid('cancel' . $appointment->getId(), $request->request->get('_token'))) {
        if (!$appointment->isCancellable()) {
            $this->addFlash('error', 'Эту запись нельзя отменить');
        } else {$appointment->setStatus(Appointment::STATUS_CANCELLED);
                $this->entityManager->flush();

                $this->addFlash('success', 'Запись отменена');
            }
        }

        return $this->redirectToRoute('app_appointments');
    }
}
📝

Формы

Компонент Symfony Forms

🚗 CarType — Форма автомобиля

📄 src/Form/CarType.php
PHP
<?php

namespace App\Form;

use App\Entity\Car;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CarType extends AbstractType
{
    private const POPULAR_BRANDS = [
        'Audi', 'BMW', 'Chevrolet', 'Ford', 'Honda',
        'Hyundai', 'Kia', 'Lada', 'Lexus', 'Mazda',
        'Mercedes-Benz', 'Mitsubishi', 'Nissan', 'Opel',
        'Peugeot', 'Renault', 'Skoda', 'Subaru',
        'Toyota', 'Volkswagen', 'Volvo',
    ];

    private const ENGINE_TYPES = [
        'Бензин' => 'petrol',
        'Дизель' => 'diesel',
        'Гибрид' => 'hybrid',
        'Электро' => 'electric',
        'Газ' => 'gas',
    ];

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $currentYear = (int) date('Y');
        $years = array_combine(
            range($currentYear, 1990),
            range($currentYear, 1990)
        );

        $brands = array_combine(self::POPULAR_BRANDS, self::POPULAR_BRANDS);

        $builder
            ->add('brand', ChoiceType::class, [
                'label' => 'Марка',
                'choices' => $brands,
                'placeholder' => 'Выберите марку',
                'attr' => ['class' => 'form-select'],
            ])
            ->add('model', TextType::class, [
                'label' => 'Модель',
                'attr' => [
                    'placeholder' => 'Например: Camry, Golf, X5',
                    'class' => 'form-control',
                ],
            ])
            ->add('year', ChoiceType::class, [
                'label' => 'Год выпуска',
                'choices' => $years,
                'placeholder' => 'Выберите год',
                'attr' => ['class' => 'form-select'],
            ])
            ->add('licensePlate', TextType::class, [
                'label' => 'Госномер',
                'attr' => [
                    'placeholder' => 'А123БВ777',
                    'class' => 'form-control',
                    'style' => 'text-transform: uppercase',
                ],
            ])
            ->add('vin', TextType::class, [
                'label' => 'VIN номер',
                'required' => false,
                'attr' => [
                    'placeholder' => '17 символов',
                    'class' => 'form-control',
                    'maxlength' => 17,
                ],
            ])
            ->add('mileage', IntegerType::class, [
                'label' => 'Пробег (км)',
                'required' => false,
                'attr' => [
                    'placeholder' => 'Текущий пробег',
                    'class' => 'form-control',
                    'min' => 0,
                ],
            ])
            ->add('color', TextType::class, [
                'label' => 'Цвет',
                'required' => false,
                'attr' => [
                    'placeholder' => 'Например: белый, чёрный',
                    'class' => 'form-control',
                ],
            ])
            ->add('engineType', ChoiceType::class, [
                'label' => 'Тип двигателя',
                'choices' => self::ENGINE_TYPES,
                'required' => false,
                'placeholder' => 'Выберите тип',
                'attr' => ['class' => 'form-select'],
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Car::class,
        ]);
    }
}

📅 AppointmentType — Форма записи

📄 src/Form/AppointmentType.php
PHP
<?php

namespace App\Form;

use App\Entity\Appointment;
use App\Entity\Car;
use App\Entity\Service;
use App\Entity\User;
use App\Repository\ServiceRepository;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class AppointmentType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        /** @var User $user */
        $user = $options['user'];

        $builder
            ->add('car', EntityType::class, [
                'label' => 'Автомобиль',
                'class' => Car::class,
                'choice_label' => fn(Car $car) => $car->__toString(),
                'query_builder' => fn(EntityRepository $er) =>
                    $er->createQueryBuilder('c')
                        ->where('c.owner = :user')
                        ->setParameter('user', $user)
                        ->orderBy('c.brand', 'ASC'),
                'placeholder' => 'Выберите автомобиль',
                'attr' => ['class' => 'form-select'],
            ])
            ->add('services', EntityType::class, [
                'label' => 'Услуги',
                'class' => Service::class,
                'choice_label' => fn(Service $s) =>
                    sprintf('%s — %s ₽ (%s)',
                        $s->getName(),
                        number_format($s->getPriceAsFloat(), 0, ',', ' '),
                        $s->getDurationFormatted()
                    ),
                'query_builder' => fn(ServiceRepository $sr) =>
                    $sr->createQueryBuilder('s')
                        ->where('s.isActive = true')
                        ->orderBy('s.category', 'ASC')
                        ->addOrderBy('s.name', 'ASC'),
                'multiple' => true,
                'expanded' => true, // Чекбоксы вместо select
                'group_by' => fn(Service $s) => $s->getCategoryName(),
                'attr' => ['class' => 'services-list'],
            ])
            ->add('scheduledAt', DateTimeType::class, [
                'label' => 'Дата и время',
                'widget' => 'single_text',
                'html5' => true,
                'attr' => [
                    'class' => 'form-control',
                    'min' => (new \DateTime('+1 day 09:00'))->format('Y-m-d\TH:i'),
                ],
            ])
            ->add('notes', TextareaType::class, [
                'label' => 'Комментарий к заявке',
                'required' => false,
                'attr' => [
                    'placeholder' => 'Опишите проблему или пожелания...',
                    'class' => 'form-control',
                    'rows' => 4,
                ],
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Appointment::class,
        ]);

        $resolver->setRequired('user');
        $resolver->setAllowedTypes('user', User::class);
    }
}
🎨

Шаблоны Twig

Представление данных пользователю

📄 base.html.twig — Базовый шаблон

📄 templates/base.html.twig
Twig
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}АвтоСервис{% endblock %}</title>

    {# Bootstrap CSS #}
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
          rel="stylesheet">
    {# Bootstrap Icons #}
    <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"
          rel="stylesheet">

    {% block stylesheets %}{% endblock %}
</head>
<body class="d-flex flex-column min-vh-100">

    {# Навигация #}
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container">
            <a class="navbar-brand" href="{{ path('app_home') }}">
                <i class="bi bi-car-front-fill me-2"></i>
                АвтоСервис
            </a>

            <button class="navbar-toggler" type="button"
                    data-bs-toggle="collapse" data-bs-target="#navbarNav">
                <span class="navbar-toggler-icon"></span>
            </button>

            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav me-auto">
                    <li class="nav-item">
                        <a class="nav-link" href="{{ path('app_services') }}">Услуги</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="{{ path('app_prices') }}">Цены</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="{{ path('app_contacts') }}">Контакты</a>
                    </li>
                </ul>

                <ul class="navbar-nav">
                    {% if app.user %}
                        <li class="nav-item dropdown">
                            <a class="nav-link dropdown-toggle" href="#"
                               data-bs-toggle="dropdown">
                                <i class="bi bi-person-circle me-1"></i>
                                {{ app.user.firstName }}
                            </a>
                            <ul class="dropdown-menu dropdown-menu-end">
                                <li>
                                    <a class="dropdown-item" href="{{ path('app_dashboard') }}">
                                        <i class="bi bi-speedometer2 me-2"></i>Личный кабинет
                                    </a>
                                </li>
                                <li>
                                    <a class="dropdown-item" href="{{ path('app_cars') }}">
                                        <i class="bi bi-car-front me-2"></i>Мои авто
                                    </a>
                                </li>
                                <li>
                                    <a class="dropdown-item" href="{{ path('app_appointments') }}">
                                        <i class="bi bi-calendar-check me-2"></i>Мои записи
                                    </a>
                                </li>
                                <li><hr class="dropdown-divider"></li>
                                <li>
                                    <a class="dropdown-item" href="{{ path('app_profile') }}">
                                        <i class="bi bi-gear me-2"></i>Настройки
                                    </a>
                                </li>
                                <li>
                                    <a class="dropdown-item text-danger" href="{{ path('app_logout') }}">
                                        <i class="bi bi-box-arrow-right me-2"></i>Выйти
                                    </a>
                                </li>
                            </ul>
                        </li>
                    {% else %}
                        <li class="nav-item">
                            <a class="nav-link" href="{{ path('app_login') }}">Войти</a>
                        </li>
                        <li class="nav-item">
                            <a class="btn btn-primary ms-2" href="{{ path('app_register') }}">
                                Регистрация
                            </a>
                        </li>
                    {% endif %}
                </ul>
            </div>
        </div>
    </nav>

    {# Flash сообщения #}
    <div class="container mt-3">
        {% for type, messages in app.flashes %}
            {% for message in messages %}
                <div class="alert alert-{{ type == 'error' ? 'danger' : type }} alert-dismissible fade show">
                    {{ message }}
                    <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                </div>
            {% endfor %}
        {% endfor %}
    </div>

    {# Основной контент #}
    <main class="flex-grow-1">
        {% block body %}{% endblock %}
    </main>

    {# Футер #}
    <footer class="bg-dark text-light py-4 mt-auto">
        <div class="container">
            <div class="row">
                <div class="col-md-4">
                    <h5>АвтоСервис</h5>
                    <p class="text-muted">
                        Качественный ремонт и обслуживание вашего автомобиля
                    </p>
                </div>
                <div class="col-md-4">
                    <h5>Контакты</h5>
                    <p class="text-muted mb-1">
                        <i class="bi bi-telephone me-2"></i>+7 (999) 123-45-67
                    </p>
                    <p class="text-muted mb-1">
                        <i class="bi bi-envelope me-2"></i>info@autoservice.ru
                    </p>
                </div>
                <div class="col-md-4">
                    <h5>Режим работы</h5>
                    <p class="text-muted mb-1">Пн-Пт: 9:00 - 20:00</p>
                    <p class="text-muted mb-1">Сб: 10:00 - 18:00</p>
                    <p class="text-muted">Вс: выходной</p>
                </div>
            </div>
            <hr class="my-3">
            <p class="text-center text-muted mb-0">
                © {{ "now"|date("Y") }} АвтоСервис. Все права защищены.
            </p>
        </div>
    </footer>

    {# Bootstrap JS #}
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
    {% block javascripts %}{% endblock %}
</body>
</html>

📊 dashboard/index.html.twig — Личный кабинет

📄 templates/dashboard/index.html.twig
Twig
{% extends 'base.html.twig' %}

{% block title %}Личный кабинет — АвтоСервис{% endblock %}

{% block body %}
<div class="container py-4">
    {# Приветствие #}
    <div class="d-flex justify-content-between align-items-center mb-4">
        <div>
            <h1 class="h3 mb-1">
                Здравствуйте, {{ app.user.firstName }}!
            </h1>
            <p class="text-muted mb-0">
                Добро пожаловать в личный кабинет
            </p>
        </div>
        <a href="{{ path('app_appointment_new') }}" class="btn btn-primary">
            <i class="bi bi-plus-lg me-1"></i>
            Записаться на услугу
        </a>
    </div>

    {# Карточки статистики #}
    <div class="row g-4 mb-4">
        <div class="col-md-3">
            <div class="card border-0 bg-primary bg-opacity-10">
                <div class="card-body">
                    <div class="d-flex align-items-center">
                        <div class="flex-shrink-0">
                            <i class="bi bi-car-front fs-1 text-primary"></i>
                        </div>
                        <div class="flex-grow-1 ms-3">
                            <h2 class="mb-0">{{ stats.carsCount }}</h2>
                            <p class="text-muted mb-0">Автомобилей</p>
                        </div>
                    </div>
                </div>
            </div>
        </div>

        <div class="col-md-3">
            <div class="card border-0 bg-success bg-opacity-10">
                <div class="card-body">
                    <div class="d-flex align-items-center">
                        <div class="flex-shrink-0">
                            <i class="bi bi-calendar-check fs-1 text-success"></i>
                        </div>
                        <div class="flex-grow-1 ms-3">
                            <h2 class="mb-0">{{ stats.appointmentsCount }}</h2>
                            <p class="text-muted mb-0">Всего записей</p>
                        </div>
                    </div>
                </div>
            </div>
        </div>

        <div class="col-md-3">
            <div class="card border-0 bg-warning bg-opacity-10">
                <div class="card-body">
                    <div class="d-flex align-items-center">
                        <div class="flex-shrink-0">
                            <i class="bi bi-clock-history fs-1 text-warning"></i>
                        </div>
                        <div class="flex-grow-1 ms-3">
                            <h2 class="mb-0">{{ stats.pendingCount }}</h2>
                            <p class="text-muted mb-0">Активных</p>
                        </div>
                    </div>
                </div>
            </div>
        </div>

        <div class="col-md-3">
            <div class="card border-0 bg-danger bg-opacity-10">
                <div class="card-body">
                    <div class="d-flex align-items-center">
                        <div class="flex-shrink-0">
                            <i class="bi bi-receipt fs-1 text-danger"></i>
                        </div>
                        <div class="flex-grow-1 ms-3">
                            <h2 class="mb-0">{{ stats.unpaidInvoices }}</h2>
                            <p class="text-muted mb-0">К оплате</p>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <div class="row g-4">
        {# Ближайшие записи #}
        <div class="col-lg-8">
            <div class="card">
                <div class="card-header d-flex justify-content-between align-items-center">
                    <h5 class="mb-0">
                        <i class="bi bi-calendar3 me-2"></i>
                        Ближайшие записи
                    </h5>
                    <a href="{{ path('app_appointments') }}" class="btn btn-sm btn-outline-primary">
                        Все записи
                    </a>
                </div>
                <div class="card-body">
                    {% if upcomingAppointments is empty %}
                        <div class="text-center py-4">
                            <i class="bi bi-calendar-x fs-1 text-muted"></i>
                            <p class="text-muted mt-2">Нет предстоящих записей</p><a href="{{ path('app_appointment_new') }}" class="btn btn-primary">
                                Записаться
                            </a>
                        </div>
                    {% else %}
                        <div class="table-responsive">
                            <table class="table table-hover mb-0">
                                <thead>
                                    <tr>
                                        <th>Дата</th>
                                        <th>Автомобиль</th>
                                        <th>Услуги</th>
                                        <th>Статус</th>
                                        <th></th>
                                    </tr>
                                </thead>
                                <tbody>
                                    {% for appointment in upcomingAppointments %}
                                        <tr>
                                            <td>
                                                <strong>{{ appointment.scheduledAt|date('d.m.Y') }}</strong><br>
                                                <small class="text-muted">
                                                    {{ appointment.scheduledAt|date('H:i') }}
                                                </small>
                                            </td>
                                            <td>
                                                {{ appointment.car.brand }} {{ appointment.car.model }}<br>
                                                <small class="text-muted">
                                                    {{ appointment.car.licensePlate }}
                                                </small>
                                            </td>
                                            <td>
                                                {% for service in appointment.services|slice(0, 2) %}
                                                    <span class="badge bg-light text-dark me-1">
                                                        {{ service.name }}
                                                    </span>
                                                {% endfor %}
                                                {% if appointment.services|length > 2 %}
                                                    <span class="badge bg-secondary">
                                                        +{{ appointment.services|length - 2 }}
                                                    </span>
                                                {% endif %}
                                            </td>
                                            <td>
                                                <span class="badge bg-{{ appointment.statusColor }}">
                                                    {{ appointment.statusName }}
                                                </span>
                                            </td>
                                            <td>
                                                <a href="{{ path('app_appointment_show', {id: appointment.id}) }}"
                                                   class="btn btn-sm btn-outline-secondary">
                                                    <i class="bi bi-eye"></i>
                                                </a>
                                            </td>
                                        </tr>
                                    {% endfor %}
                                </tbody>
                            </table>
                        </div>
                    {% endif %}
                </div>
            </div>
        </div>

        {# Мои автомобили #}
        <div class="col-lg-4">
            <div class="card">
                <div class="card-header d-flex justify-content-between align-items-center">
                    <h5 class="mb-0">
                        <i class="bi bi-car-front me-2"></i>
                        Мои авто
                    </h5>
                    <a href="{{ path('app_car_new') }}" class="btn btn-sm btn-outline-primary">
                        <i class="bi bi-plus"></i>
                    </a>
                </div>
                <div class="list-group list-group-flush">
                    {% if cars is empty %}
                        <div class="list-group-item text-center py-4">
                            <i class="bi bi-car-front fs-1 text-muted"></i>
                            <p class="text-muted mt-2 mb-2">
                                Нет добавленных автомобилей
                            </p>
                            <a href="{{ path('app_car_new') }}" class="btn btn-sm btn-primary">
                                Добавить авто
                            </a>
                        </div>
                    {% else %}
                        {% for car in cars %}
                            <a href="{{ path('app_car_show', {id: car.id}) }}"
                               class="list-group-item list-group-item-action">
                                <div class="d-flex justify-content-between align-items-center">
                                    <div>
                                        <strong>{{ car.brand }} {{ car.model }}</strong>
                                        <br>
                                        <small class="text-muted">
                                            {{ car.licensePlate }} • {{ car.year }} г.
                                        </small>
                                    </div>
                                    <i class="bi bi-chevron-right text-muted"></i>
                                </div>
                            </a>
                        {% endfor %}
                        {% if stats.carsCount > 3 %}
                            <a href="{{ path('app_cars') }}"
                               class="list-group-item list-group-item-action text-center text-primary">
                                Показать все ({{ stats.carsCount }})
                            </a>
                        {% endif %}
                    {% endif %}
                </div>
            </div>
        </div>
    </div>
</div>
{% endblock %}

🔐 security/login.html.twig — Страница входа

📄 templates/security/login.html.twig
Twig
{% extends 'base.html.twig' %}

{% block title %}Вход — АвтоСервис{% endblock %}

{% block body %}
<div class="container py-5">
    <div class="row justify-content-center">
        <div class="col-md-5">
            <div class="card shadow-sm">
                <div class="card-body p-4">
                    <div class="text-center mb-4">
                        <i class="bi bi-person-circle fs-1 text-primary"></i>
                        <h2 class="mt-2">Вход в систему</h2>
                    </div>

                    {% if error %}
                        <div class="alert alert-danger">
                            <i class="bi bi-exclamation-triangle me-2"></i>
                            {{ error.messageKey|trans(error.messageData, 'security') }}
                        </div>
                    {% endif %}

                    <form method="post">
                        <div class="mb-3">
                            <label for="inputEmail" class="form-label">Email</label>
                            <div class="input-group">
                                <span class="input-group-text">
                                    <i class="bi bi-envelope"></i>
                                </span>
                                <input type="email" name="_username" id="inputEmail"
                                       class="form-control" placeholder="example@mail.ru"
                                       value="{{ last_username }}" required autofocus>
                            </div>
                        </div>

                        <div class="mb-3">
                            <label for="inputPassword" class="form-label">Пароль</label>
                            <div class="input-group">
                                <span class="input-group-text">
                                    <i class="bi bi-lock"></i>
                                </span>
                                <input type="password" name="_password" id="inputPassword"
                                       class="form-control" placeholder="Ваш пароль" required>
                            </div>
                        </div>

                        <div class="d-flex justify-content-between align-items-center mb-4">
                            <div class="form-check">
                                <input type="checkbox" name="_remember_me" id="rememberMe"
                                       class="form-check-input">
                                <label class="form-check-label" for="rememberMe">
                                    Запомнить меня
                                </label>
                            </div>
                            <a href="{{ path('app_forgot_password') }}" class="text-decoration-none">
                                Забыли пароль?
                            </a>
                        </div>

                        <input type="hidden" name="_csrf_token"
                               value="{{ csrf_token('authenticate') }}">

                        <button type="submit" class="btn btn-primary w-100 mb-3">
                            <i class="bi bi-box-arrow-in-right me-2"></i>
                            Войти
                        </button>

                        <p class="text-center text-muted mb-0">
                            Нет аккаунта?
                            <a href="{{ path('app_register') }}">Зарегистрироваться</a>
                        </p>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
{% endblock %}

Сервисы

Бизнес-логика приложения

В Symfony бизнес-логику принято выносить в отдельные сервисы. Это делает код контроллеров чище и позволяет переиспользовать логику.

📅 AppointmentService

📄 src/Service/AppointmentService.php
PHP
<?php

namespace App\Service;

use App\Entity\Appointment;
use App\Entity\User;
use App\Repository\AppointmentRepository;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;

class AppointmentService
{
    public function __construct(
        private AppointmentRepository $appointmentRepository,
        private MailerInterface $mailer,
        private string $adminEmail = 'admin@autoservice.ru',
    ) {}

    /**
     * Проверяет, свободен ли временной слот
     */
    public function isTimeSlotAvailable(Appointment $appointment): bool
    {
        $scheduledAt = $appointment->getScheduledAt();

        if (!$scheduledAt) {
            return false;
        }

        // Проверяем рабочие часы (9:00 - 19:00)
        $hour = (int) $scheduledAt->format('H');
        if ($hour < 9 || $hour >= 19) {
            return false;
        }

        // Проверяем выходные (воскресенье = 0)
        $dayOfWeek = (int) $scheduledAt->format('w');
        if ($dayOfWeek === 0) {
            return false;
        }

        // Считаем продолжительность записи
        $duration = $appointment->getEstimatedDuration() ?? 60;

        // Вычисляем время окончания
        $endTime = (clone $scheduledAt)->modify("+{$duration} minutes");

        // Ищем пересекающиеся записи
        $conflicting = $this->appointmentRepository->findConflictingAppointments(
            $scheduledAt,
            $endTime,
            $appointment->getId()
        );

        return count($conflicting) === 0;
    }

    /**
     * Получает доступные временные слоты на дату
     */
    public function getAvailableSlots(\DateTime $date, int $durationMinutes = 60): array
    {
        $slots = [];
        $workStart = 9;  // 9:00
        $workEnd = 19;   // 19:00
        $slotInterval = 30; // Интервал слотов в минутах

        // Получаем все записи на эту дату
        $bookedAppointments = $this->appointmentRepository->findByDate($date);

        // Генерируем слоты
        for ($hour = $workStart; $hour < $workEnd; $hour++) {
            for ($minute = 0; $minute < 60; $minute += $slotInterval) {
                $slotStart = (clone $date)->setTime($hour, $minute);
                $slotEnd = (clone $slotStart)->modify("+{$durationMinutes} minutes");

                // Проверяем, не выходит ли слот за рабочее время
                if ((int) $slotEnd->format('H') > $workEnd) {
                    continue;
                }

                // Проверяем пересечения с существующими записями
                $isAvailable = true;
                foreach ($bookedAppointments as $booked) {
                    $bookedStart = $booked->getScheduledAt();
                    $bookedDuration = $booked->getEstimatedDuration() ?? 60;
                    $bookedEnd = (clone $bookedStart)->modify("+{$bookedDuration} minutes");

                    if ($slotStart < $bookedEnd && $slotEnd > $bookedStart) {
                        $isAvailable = false;
                        break;
                    }
                }

                if ($isAvailable) {
                    $slots[] = $slotStart;
                }
            }
        }

        return $slots;
    }

    /**
     * Отправляет email подтверждения клиенту
     */
    public function sendConfirmationEmail(Appointment $appointment): void
    {
        $user = $appointment->getUser();

        $email = (new TemplatedEmail())
            ->from(new Address('noreply@autoservice.ru', 'АвтоСервис'))
            ->to($user->getEmail())
            ->subject('Ваша запись в автосервис')
            ->htmlTemplate('email/appointment_confirmation.html.twig')
            ->context([
                'appointment' => $appointment,
                'user' => $user,
            ]);

        $this->mailer->send($email);

        // Уведомляем администратора
        $this->notifyAdmin($appointment);
    }

    /**
     * Уведомляет администратора о новой записи
     */
    private function notifyAdmin(Appointment $appointment): void
    {
        $email = (new TemplatedEmail())
            ->from(new Address('noreply@autoservice.ru', 'АвтоСервис'))
            ->to($this->adminEmail)
            ->subject('Новая запись на обслуживание')
            ->htmlTemplate('email/admin_new_appointment.html.twig')
            ->context([
                'appointment' => $appointment,
            ]);

        $this->mailer->send($email);
    }

    /**
     * Отправляет напоминание о записи
     */
    public function sendReminder(Appointment $appointment): void
    {
        $email = (new TemplatedEmail())
            ->from(new Address('noreply@autoservice.ru', 'АвтоСервис'))
            ->to($appointment->getUser()->getEmail())
            ->subject('Напоминание о записи в автосервис')
            ->htmlTemplate('email/appointment_reminder.html.twig')
            ->context([
                'appointment' => $appointment,
            ]);

        $this->mailer->send($email);
    }
}

🗳️ Security Voter — Контроль доступа

📄 src/Security/Voter/CarVoter.php
PHP
<?php

namespace App\Security\Voter;

use App\Entity\Car;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class CarVoter extends Voter
{
    public const VIEW = 'CAR_VIEW';
    public const EDIT = 'CAR_EDIT';
    public const DELETE = 'CAR_DELETE';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])
            && $subject instanceof Car;
    }

    protected function voteOnAttribute(
        string $attribute,
        mixed $subject,
        TokenInterface $token
    ): bool {
        $user = $token->getUser();

        if (!$user instanceof User) {
            return false;
        }

        /** @var Car $car */
        $car = $subject;

        // Админ может всё
        if (in_array('ROLE_ADMIN', $user->getRoles())) {
            return true;
        }

        return match($attribute) {
            self::VIEW, self::EDIT, self::DELETE => $this->isOwner($car, $user),
            default => false,
        };
    }

    private function isOwner(Car $car, User $user): bool
    {
        return $car->getOwner() === $user;
    }
}
🗄️

Репозитории

Работа с базой данных через Doctrine

📅 AppointmentRepository

📄 src/Repository/AppointmentRepository.php
PHP
<?php

namespace App\Repository;

use App\Entity\Appointment;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Appointment>
 */
class AppointmentRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Appointment::class);
    }

    /**
     * Создаёт QueryBuilder для записей пользователя
     */
    public function createQueryBuilderForUser(
        User $user,
        ?string $status = null
    ): QueryBuilder {
        $qb = $this->createQueryBuilder('a')
            ->where('a.user = :user')
            ->setParameter('user', $user)
            ->orderBy('a.scheduledAt', 'DESC');

        if ($status) {
            $qb->andWhere('a.status = :status')
               ->setParameter('status', $status);
        }

        return $qb;
    }

    /**
     * Находит ближайшие записи пользователя
     * 
     * @return Appointment[]
     */
    public function findUpcomingByUser(User $user, int $limit = 5): array
    {
        return $this->createQueryBuilder('a')
            ->where('a.user = :user')
            ->andWhere('a.scheduledAt >= :now')
            ->andWhere('a.status IN (:statuses)')
            ->setParameter('user', $user)
            ->setParameter('now', new \DateTime())
            ->setParameter('statuses', [
                Appointment::STATUS_PENDING,
                Appointment::STATUS_CONFIRMED,
            ])
            ->orderBy('a.scheduledAt', 'ASC')
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();
    }

    /**
     * Считает записи пользователя
     */
    public function countByUser(User $user): int
    {
        return (int) $this->createQueryBuilder('a')
            ->select('COUNT(a.id)')
            ->where('a.user = :user')
            ->setParameter('user', $user)
            ->getQuery()
            ->getSingleScalarResult();
    }

    /**
     * Считает записи пользователя по статусам
     */
    public function countByUserAndStatus(User $user, array $statuses): int
    {
        return (int) $this->createQueryBuilder('a')
            ->select('COUNT(a.id)')
            ->where('a.user = :user')
            ->andWhere('a.status IN (:statuses)')
            ->setParameter('user', $user)
            ->setParameter('statuses', $statuses)
            ->getQuery()
            ->getSingleScalarResult();
    }

    /**
     * Находит записи на определённую дату
     * 
     * @return Appointment[]
     */
    public function findByDate(\DateTime $date): array
    {
        $startOfDay = (clone $date)->setTime(0, 0, 0);
        $endOfDay = (clone $date)->setTime(23, 59, 59);

        return $this->createQueryBuilder('a')
            ->where('a.scheduledAt BETWEEN :start AND :end')
            ->andWhere('a.status NOT IN (:cancelled)')
            ->setParameter('start', $startOfDay)
            ->setParameter('end', $endOfDay)
            ->setParameter('cancelled', [Appointment::STATUS_CANCELLED])
            ->orderBy('a.scheduledAt', 'ASC')
            ->getQuery()
            ->getResult();
    }

    /**
     * Находит конфликтующие записи (для проверки доступности)
     * 
     * @return Appointment[]
     */
    public function findConflictingAppointments(
        \DateTime $startTime,
        \DateTime $endTime,
        ?int $excludeId = null
    ): array {
        $qb = $this->createQueryBuilder('a')
            ->where('a.scheduledAt < :endTime')
            ->andWhere('a.status NOT IN (:cancelled)')
            ->setParameter('endTime', $endTime)
            ->setParameter('cancelled', [Appointment::STATUS_CANCELLED]);

        if ($excludeId) {
            $qb->andWhere('a.id != :excludeId')
               ->setParameter('excludeId', $excludeId);
        }

        return $qb->getQuery()->getResult();
    }

    /**
     * Находит записи, требующие напоминания (за день до визита)
     * 
     * @return Appointment[]
     */
    public function findForReminder(): array
    {
        $tomorrow = new \DateTime('tomorrow');
        $dayAfter = new \DateTime('tomorrow +1 day');

        return $this->createQueryBuilder('a')
            ->where('a.scheduledAt >= :tomorrow')
            ->andWhere('a.scheduledAt < :dayAfter')
            ->andWhere('a.status = :status')
            ->setParameter('tomorrow', $tomorrow->setTime(0, 0))
            ->setParameter('dayAfter', $dayAfter->setTime(0, 0))
            ->setParameter('status', Appointment::STATUS_CONFIRMED)
            ->getQuery()
            ->getResult();
    }
}
⚙️

Консольные команды

Автоматизация задач

📧 Команда отправки напоминаний

📄 src/Command/SendAppointmentRemindersCommand.php
PHP
<?php

namespace App\Command;

use App\Repository\AppointmentRepository;
use App\Service\AppointmentService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
name: 'app:send-reminders',
description: 'Отправляет напоминания о записях на завтра'
)]
class SendAppointmentRemindersCommand extends Command
{
public function __construct(
    private AppointmentRepository $appointmentRepository,
    private AppointmentService $appointmentService,
) {
    parent::__construct();
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $io = new SymfonyStyle($input, $output);

    $io->title('Отправка напоминаний о записях');

    $appointments = $this->appointmentRepository->findForReminder();

    if (empty($appointments)) {
        $io->info('Нет записей для напоминания.');
        return Command::SUCCESS;
    }

    $io->progressStart(count($appointments));

    $sent = 0;
    $errors = 0;

    foreach ($appointments as $appointment) {
        try {
            $this->appointmentService->sendReminder($appointment);
            $sent++;
        } catch (\Exception $e) {
            $io->error(sprintf(
                'Ошибка отправки для записи #%d: %s',
                $appointment->getId(),
                $e->getMessage()
            ));
            $errors++;
        }

        $io->progressAdvance();
    }

    $io->progressFinish();

    $io->success(sprintf(
        'Отправлено напоминаний: %d. Ошибок: %d.',
        $sent,
        $errors
    ));

    return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
}
⏰ Автозапуск через CRON

Добавьте команду в crontab для ежедневного выполнения:

0 10 * * * cd /var/www/autoservice && php bin/console app:send-reminders
🔧

Админ-панель

EasyAdmin Bundle

Для быстрого создания админ-панели используем EasyAdmin — один из самых популярных бандлов для Symfony.

📦 Установка EasyAdmin

Bash
composer require easycorp/easyadmin-bundle

🎛️ DashboardController

📄 src/Controller/Admin/DashboardController.php
PHP
<?php

namespace App\Controller\Admin;

use App\Entity\Appointment;
use App\Entity\Car;
use App\Entity\Service;
use App\Entity\User;
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class DashboardController extends AbstractDashboardController
{
#[Route('/admin', name: 'admin')]
public function index(): Response
{
    $adminUrlGenerator = $this->container->get(AdminUrlGenerator::class);

    // Редирект на список записей
    return $this->redirect(
        $adminUrlGenerator
            ->setController(AppointmentCrudController::class)
            ->generateUrl()
    );
}

public function configureDashboard(): Dashboard
{
    return Dashboard::new()
        ->setTitle('🚗 АвтоСервис')
        ->setFaviconPath('favicon.ico')
        ->renderContentMaximized();
}

public function configureMenuItems(): iterable
{
    yield MenuItem::linkToDashboard('Панель управления', 'fa fa-home');

    yield MenuItem::section('Записи');
    yield MenuItem::linkToCrud('Все записи', 'fa fa-calendar', Appointment::class);

    yield MenuItem::section('Каталог');
    yield MenuItem::linkToCrud('Услуги', 'fa fa-wrench', Service::class);

    yield MenuItem::section('Клиенты');
    yield MenuItem::linkToCrud('Пользователи', 'fa fa-users', User::class);
    yield MenuItem::linkToCrud('Автомобили', 'fa fa-car', Car::class);

    yield MenuItem::section('Система');
    yield MenuItem::linkToUrl('На сайт', 'fa fa-external-link', '/')
        ->setLinkTarget('_blank');
}
}

📅 AppointmentCrudController

📄 src/Controller/Admin/AppointmentCrudController.php
PHP
<?php

namespace App\Controller\Admin;

use App\Entity\Appointment;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
use EasyCorp\Bundle\EasyAdminBundle\Filter\ChoiceFilter;
use EasyCorp\Bundle\EasyAdminBundle\Filter\DateTimeFilter;

class AppointmentCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
    return Appointment::class;
}

public function configureCrud(Crud $crud): Crud
{
    return $crud
        ->setEntityLabelInSingular('Запись')
        ->setEntityLabelInPlural('Записи')
        ->setDefaultSort(['scheduledAt' => 'DESC'])
        ->setSearchFields(['user.email', 'user.firstName', 'car.licensePlate'])
        ->setPaginatorPageSize(20);
}

public function configureFields(string $pageName): iterable
{
    yield IdField::new('id')->hideOnForm();

    yield AssociationField::new('user', 'Клиент')
        ->setFormTypeOption('choice_label', 'fullName');

    yield AssociationField::new('car', 'Автомобиль');

    yield AssociationField::new('services', 'Услуги')
        ->setFormTypeOption('by_reference', false);

    yield DateTimeField::new('scheduledAt', 'Дата и время')
        ->setFormat('dd.MM.yyyy HH:mm');

    yield ChoiceField::new('status', 'Статус')
        ->setChoices(array_flip(Appointment::STATUSES))
        ->renderAsBadges([
            Appointment::STATUS_PENDING => 'warning',
            Appointment::STATUS_CONFIRMED => 'info',
            Appointment::STATUS_IN_PROGRESS => 'primary',
            Appointment::STATUS_COMPLETED => 'success',
            Appointment::STATUS_CANCELLED => 'danger',
        ]);

    yield MoneyField::new('totalPrice', 'Сумма')
        ->setCurrency('RUB')
        ->hideOnForm();

    yield TextareaField::new('notes', 'Комментарий клиента')
        ->hideOnIndex();

    yield TextareaField::new('adminNotes', 'Заметки администратора')
        ->hideOnIndex();

    yield DateTimeField::new('createdAt', 'Создана')
        ->hideOnForm()
        ->setFormat('dd.MM.yyyy HH:mm');
}

public function configureFilters(Filters $filters): Filters
{
    return $filters
        ->add(ChoiceFilter::new('status')
            ->setChoices(array_flip(Appointment::STATUSES)))
        ->add(DateTimeFilter::new('scheduledAt'))
        ->add('user')
        ->add('car');
}

public function configureActions(Actions $actions): Actions
{
    // Кнопка "Подтвердить"
    $confirmAction = Action::new('confirm', 'Подтвердить', 'fa fa-check')
        ->linkToCrudAction('confirmAppointment')
        ->setCssClass('btn btn-success')
        ->displayIf(fn(Appointment $a) =>
            $a->getStatus() === Appointment::STATUS_PENDING
        );

    return $actions
        ->add(Crud::PAGE_INDEX, Action::DETAIL)
        ->add(Crud::PAGE_DETAIL, $confirmAction)
        ->add(Crud::PAGE_INDEX, $confirmAction);
}
}
🧪

Тестирование

PHPUnit и функциональные тесты

🔬 Unit-тест сервиса

📄 tests/Service/AppointmentServiceTest.php
PHP
<?php

namespace App\Tests\Service;

use App\Entity\Appointment;
use App\Entity\Service;
use App\Repository\AppointmentRepository;
use App\Service\AppointmentService;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\MailerInterface;

class AppointmentServiceTest extends TestCase
{
private AppointmentService $service;
private $appointmentRepository;
private $mailer;

protected function setUp(): void
{
    $this->appointmentRepository = $this->createMock(AppointmentRepository::class);
    $this->mailer = $this->createMock(MailerInterface::class);

    $this->service = new AppointmentService(
        $this->appointmentRepository,
        $this->mailer
    );
}

public function testIsTimeSlotAvailableReturnsFalseForWeekend(): void
{
    $appointment = new Appointment();
    // Воскресенье
    $appointment->setScheduledAt(new \DateTime('next Sunday 10:00'));

    $result = $this->service->isTimeSlotAvailable($appointment);

    $this->assertFalse($result);
}

public function testIsTimeSlotAvailableReturnsFalseForEarlyMorning(): void
{
    $appointment = new Appointment();
    // 7 утра в понедельник
    $appointment->setScheduledAt(new \DateTime('next Monday 07:00'));

    $result = $this->service->isTimeSlotAvailable($appointment);

    $this->assertFalse($result);
}

public function testIsTimeSlotAvailableReturnsTrueForValidSlot(): void
{
    $appointment = new Appointment();
    $appointment->setScheduledAt(new \DateTime('next Monday 10:00'));

    // Мок: нет конфликтующих записей
    $this->appointmentRepository
        ->expects($this->once())
        ->method('findConflictingAppointments')
        ->willReturn([]);

    $result = $this->service->isTimeSlotAvailable($appointment);

    $this->assertTrue($result);
}

public function testGetAvailableSlotsReturnsArrayOfDateTimes(): void
{
    $date = new \DateTime('next Monday');

    $this->appointmentRepository
        ->expects($this->once())
        ->method('findByDate')
        ->willReturn([]);

    $slots = $this->service->getAvailableSlots($date);

    $this->assertIsArray($slots);
    $this->assertNotEmpty($slots);
    $this->assertContainsOnlyInstancesOf(\DateTime::class, $slots);
}
}

🌐 Функциональный тест контроллера

📄 tests/Controller/DashboardControllerTest.php
PHP
<?php

namespace App\Tests\Controller;

use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class DashboardControllerTest extends WebTestCase
{
public function testDashboardRequiresAuthentication(): void
{
    $client = static::createClient();

    $client->request('GET', '/dashboard');

    $this->assertResponseRedirects('/login');
}

public function testDashboardIsAccessibleForAuthenticatedUser(): void
{
    $client = static::createClient();

    // Получаем тестового пользователя из базы
    $userRepository = static::getContainer()->get('doctrine')
        ->getRepository(User::class);
    $testUser = $userRepository->findOneByEmail('test@example.com');

    // Логинимся
    $client->loginUser($testUser);

    $client->request('GET', '/dashboard');

    $this->assertResponseIsSuccessful();
    $this->assertSelectorTextContains('h1', 'Здравствуйте');
}

public function testDashboardShowsUserCars(): void
{
    $client = static::createClient();

    $userRepository = static::getContainer()->get('doctrine')
        ->getRepository(User::class);
    $testUser = $userRepository->findOneByEmail('test@example.com');

    $client->loginUser($testUser);

    $crawler = $client->request('GET', '/dashboard');

    // Проверяем наличие секции "Мои авто"
    $this->assertSelectorExists('.card-header:contains("Мои авто")');
}
}

▶️ Запуск тестов

Bash
# Все тесты
php bin/phpunit

# Конкретный тест
php bin/phpunit tests/Service/AppointmentServiceTest.php

# С покрытием кода
php bin/phpunit --coverage-html coverage/

# Только unit-тесты
php bin/phpunit --testsuite unit
🚀

Развёртывание

Docker и продакшен

🐳 Docker Compose для разработки

📄 docker-compose.yml
YAML
version: '3.8'

services:
# PHP-FPM
php:
build:
  context: .
  dockerfile: docker/php/Dockerfile
volumes:
  - .:/var/www/html
depends_on:
  - database
  - redis
environment:
  DATABASE_URL: mysql://app:secret@database:3306/autoservice?serverVersion=8.0
  REDIS_URL: redis://redis:6379

# Nginx
nginx:
image: nginx:alpine
ports:
  - "8080:80"
volumes:
  - .:/var/www/html
  - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
  - php

# MySQL
database:
image: mysql:8.0
environment:
  MYSQL_ROOT_PASSWORD: root
  MYSQL_DATABASE: autoservice
  MYSQL_USER: app
  MYSQL_PASSWORD: secret
volumes:
  - db_data:/var/lib/mysql
ports:
  - "3306:3306"

# Redis для кэша и сессий
redis:
image: redis:alpine
ports:
  - "6379:6379"

# Mailhog для тестирования email
mailhog:
image: mailhog/mailhog
ports:
  - "1025:1025"
  - "8025:8025"

volumes:
db_data:

🐘 Dockerfile для PHP

📄 docker/php/Dockerfile
Dockerfile
FROM php:8.2-fpm-alpine

# Установка расширений
RUN apk add --no-cache \
icu-dev \
libzip-dev \
&& docker-php-ext-install \
intl \
pdo_mysql \
zip \
opcache

# Установка Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Установка Symfony CLI
RUN curl -sS https://get.symfony.com/cli/installer | bash \
&& mv /root/.symfony5/bin/symfony /usr/local/bin/symfony

WORKDIR /var/www/html

# Копируем только файлы зависимостей для кэширования
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader

# Копируем остальной код
COPY . .

# Генерируем автозагрузчик и очищаем кэш
RUN composer dump-autoload --optimize \
&& php bin/console cache:clear --env=prod \
&& php bin/console assets:install

# Права на запись
RUN chown -R www-data:www-data var/

📋 Чеклист деплоя

Bash
# 1. Установка зависимостей (без dev)
composer install --no-dev --optimize-autoloader

# 2. Очистка и прогрев кэша
php bin/console cache:clear --env=prod
php bin/console cache:warmup --env=prod

# 3. Миграции базы данных
php bin/console doctrine:migrations:migrate --no-interaction

# 4. Установка ассетов
php bin/console assets:install public

# 5. Компиляция JS/CSS (если используете Webpack Encore)
npm run build

# 6. Проверка конфигурации
php bin/console debug:container --env=prod

Заключение

Итоги и дальнейшее развитие

Мы создали полнофункциональное веб-приложение на Symfony 7, включающее:

🔐 Авторизация

Регистрация, вход, подтверждение email, роли пользователей

🗄️ База данных

Doctrine ORM, сущности, связи, миграции, репозитории

📝 Формы

Валидация, типы полей, обработка данных

🎨 Шаблоны

Twig, наследование, компоненты, Bootstrap

⚡ Сервисы

Бизнес-логика, email-уведомления, Voter

🔧 Админка

EasyAdmin, CRUD-операции, фильтры

📚 Полезные ресурсы

🎉 Поздравляем!

Вы изучили основы разработки на Symfony 7. Теперь вы можете создавать профессиональные веб-приложения с чистой архитектурой и современными практиками.