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
# Установка 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: Создание проекта
# Создаём новый проект Symfony
symfony new autoservice --webapp
# Переходим в директорию проекта
cd autoservice
# Или через Composer (альтернатива)
composer create-project symfony/skeleton autoservice
cd autoservice
composer require webapp
📦 Шаг 3: Установка дополнительных пакетов
# Основные пакеты (уже включены в 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 - Настройки окружения
# Режим приложения
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: Запуск сервера
# Запуск встроенного сервера Symfony
symfony server:start
# Или в фоновом режиме
symfony server:start -d
# Откройте в браузере: https://127.0.0.1:8000
Symfony CLI автоматически настраивает HTTPS для локальной разработки.
Если нужен HTTP, используйте symfony server:start --no-tls
Структура проекта
Организация файлов и директорий Symfony
База данных
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
🛠️ Создание базы данных
# Создание базы данных
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 — Пользователь
<?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 — Автомобиль
<?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 — Услуга
<?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 — Запись на услугу
<?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
⚙️ Конфигурация безопасности
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 — Вход и регистрация
<?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');
}
}
📝 Форма регистрации
<?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 — Главная страница кабинета
<?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 — Управление автомобилями
<?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 — Записи на услуги
<?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 — Форма автомобиля
<?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 — Форма записи
<?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 — Базовый шаблон
<!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 — Личный кабинет
{% 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 — Страница входа
{% 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
<?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 — Контроль доступа
<?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
<?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();
}
}
Консольные команды
Автоматизация задач
📧 Команда отправки напоминаний
<?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;
}
}
Добавьте команду в crontab для ежедневного выполнения:
0 10 * * * cd /var/www/autoservice && php bin/console app:send-reminders
Админ-панель
EasyAdmin Bundle
Для быстрого создания админ-панели используем EasyAdmin — один из самых популярных бандлов для Symfony.
📦 Установка EasyAdmin
composer require easycorp/easyadmin-bundle
🎛️ DashboardController
<?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
<?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-тест сервиса
<?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);
}
}
🌐 Функциональный тест контроллера
<?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("Мои авто")');
}
}
▶️ Запуск тестов
# Все тесты
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 для разработки
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
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/
📋 Чеклист деплоя
# 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
- SymfonyCasts — видеокурсы
- Symfony Demo — официальный пример
- Блог Symfony
Вы изучили основы разработки на Symfony 7. Теперь вы можете создавать профессиональные веб-приложения с чистой архитектурой и современными практиками.