Symfony Backend (Mart)

Конвенции для apps/backend/ — Symfony 7 + API Platform + Doctrine ORM + PostgreSQL 18.

Схемы БД проекта описаны в docs/db/ (33 PostgreSQL-схемы: public, billing, marketplace, …).


Структура src/

src/
├── Api/
│   ├── Resource/              # DTO с #[ApiResource] — контракт HTTP /api/…
│   │   ├── Public/
│   │   ├── Billing/
│   │   └── …/
│   └── State/                 # Provider, Processor для Api Resource
│       ├── Public/
│       └── …/
├── Controller/                # HTTP вне API Platform (health, webhooks, …)
├── Console/                   # Symfony Console (bin/console app:…)
│   ├── Public/
│   └── …/
├── Infrastructure/
│   ├── Entity/                # Doctrine, «тупые» — без #[ApiResource]
│   │   ├── Public/
│   │   └── …/
│   └── Repository/
│       ├── Public/
│       └── …/
└── Kernel.php

Domain / Application (src/Domain/, src/Application/) — по мере роста, когда правил станет много. Persistence всегда в Infrastructure/.

Разделение слоёв

СлойПапкаНазначение
APIApi/Resource/JSON-контракт, #[ApiResource]
APIApi/State/откуда/куда данные для ресурса
PersistenceInfrastructure/Entity/таблицы PostgreSQL, Doctrine mapping
PersistenceInfrastructure/Repository/запросы к БД
CLIConsole/точка входа bin/console, без бизнес-логики

Entity не экспонируется в API напрямую.


Console

Symfony-команды — src/Console/, namespace App\Console (подпапки по схеме/домену — опционально: Console/Public/).

<?php

declare(strict_types=1);

namespace App\Console;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:campaign:sync', description: 'Sync campaigns from external source')]
final class SyncCampaignsCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // делегировать в сервис Application/ — не писать логику здесь
        return Command::SUCCESS;
    }
}

Правила:

  • Класс команды — тонкий: parse args/options → вызов сервиса → exit code.
  • Бизнес-логика — в Application/ (когда появится) или временно в dedicated service.
  • Имя команды: app:<domain>:<action> (например app:billing:recalculate).
  • Создание: php bin/console make:command — namespace App\Console (Maker по умолчанию предлагает App\Command).

Console vs bin/ в корне репозитория

apps/backend/src/Console/bin/<script>/ (корень monorepo)
RuntimePHP, Symfony DI, Doctrineчаще Python, автономно
Когдаcron, миграции данных, maintenance внутри приложенияETL, разовые скрипты, не нужен Symfony container
Документацияdocs/symfony.mdbin/<script>/README.md + just bin-*

Entity (Infrastructure)

Расположение и namespace

PostgreSQL schemaПапкаNamespace
publicInfrastructure/Entity/Public/App\Infrastructure\Entity\Public
billingInfrastructure/Entity/Billing/App\Infrastructure\Entity\Billing
Infrastructure/Entity/<Schema>/App\Infrastructure\Entity\<Schema>

Имя подпапки — PascalCase от имени схемы (marketplaceMarketplace, publicPublic).

Пример

<?php

declare(strict_types=1);

namespace App\Infrastructure\Entity\Public;

use App\Infrastructure\Repository\Public\CampaignRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: CampaignRepository::class)]
#[ORM\Table(name: 'campaign', schema: 'public')]
class Campaign
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private string $name = '';

    // getters/setters — без бизнес-логики и без #[ApiResource]
}

Правила:

  • schema в #[ORM\Table] обязан совпадать с подпапкой (Public/schema: 'public').
  • Имена таблиц — snake_case, как в docs/db/<schema>/.
  • Без #[ApiResource] на Entity.

Новая сущность

cd apps/backend
php bin/console make:entity
# namespace: App\Infrastructure\Entity\Public\Campaign

php bin/console make:migration
php bin/console doctrine:migrations:migrate

Maker по умолчанию предлагает App\Entityменять на App\Infrastructure\Entity\<Schema>.


Repository

Зеркальная структура Entity:

EntityRepository
Infrastructure/Entity/Public/Campaign.phpInfrastructure/Repository/Public/CampaignRepository.php
App\Infrastructure\Entity\Public\CampaignApp\Infrastructure\Repository\Public\CampaignRepository
  • Класс final, один репозиторий на Entity.
  • Кастомные запросы — методы репозитория, не в Entity.

Api Resource (DTO)

Расположение и namespace

PostgreSQL schemaПапкаNamespace
publicApi/Resource/Public/App\Api\Resource\Public
billingApi/Resource/Billing/App\Api\Resource\Billing
Api/Resource/<Schema>/App\Api\Resource\<Schema>

Provider / Processor — в Api/State/<Schema>/, namespace App\Api\State\<Schema>.

Простой CRUD (форма ≈ таблица)

DTO + маппинг на Entity через stateOptions и #[Map] (API Platform 4):

<?php

declare(strict_types=1);

namespace App\Api\Resource\Public;

use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Infrastructure\Entity\Public\Campaign as CampaignEntity;
use Symfony\Component\ObjectMapper\Attribute\Map;

#[ApiResource(
    shortName: 'Campaign',
    stateOptions: new Options(entityClass: CampaignEntity::class),
    operations: [
        new GetCollection(),
        new Get(),
        new Post(),
        new Patch(),
    ],
)]
#[Map(source: CampaignEntity::class)]
final class Campaign
{
    public ?int $id = null;
    public string $name = '';
}

Platform сам читает/пишет Entity, наружу отдаёт DTO.

Кастомное чтение (агрегаты, join'ы)

Отдельный DTO + Provider:

#[ApiResource(
    operations: [
        new GetCollection(provider: CampaignSummaryProvider::class),
        new Get(provider: CampaignSummaryProvider::class),
    ],
)]
final class CampaignSummary
{
    public function __construct(
        public int $id,
        public string $name,
        public int $ordersCount,
    ) {}
}
// App\Api\State\Public\CampaignSummaryProvider
final class CampaignSummaryProvider implements ProviderInterface
{
    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        // Repository / Query → new CampaignSummary(...)
    }
}

Запись с правилами

Input-DTO + Processor → сервис или фабрика → Entity → flush().


API Platform

Ресурсы сканируются только из src/Api/Resource/ (не Entity).

URLНазначение
/apiSwagger UI
/api/docs.jsonOpenAPI
/healthSymfony Controller (не API Platform)

Конфигурация

ФайлЧто задаёт
config/packages/doctrine.yamlmapping → Infrastructure/Entity
config/packages/api_platform.yamlscan path → Api/Resource
.env / .env.localDATABASE_URL, APP_SECRET

Команды

just back-dev       # symfony serve :8080
just back-check     # psalm + php-cs-fixer + rector
just back-test      # phpunit
composer lint:fix     # автофикс стиля

Линтинг

Psalm (level 1), PHP-CS-Fixer (@Symfony), Rector — см. .claude/rules/linting-php.md.