
API platform это полнофункциональный REST API, который вы получите за считанные минуты.Вот неполный список фич:
- Генерация CRUD
- Поддержка GraphQL
- Машиночитаемая документация API в форматах Hydra и Swagger/Open API, гененрится из метаданных PHPDoc, Serializer, Validator и Doctrine ORM / MongoDB ODM
- Хорошая удобочитаемая документация, созданная с использованием пользовательского интерфейса Swagger (включая песочницу) и / или ReDoc
- Пагинация
- Куча фильтров
- Проверка с использованием компонента Symfony Validator (с поддержкой групп)
- Расширенные правила аутентификации и авторизации
- Расширенная сериализация благодаря компоненту Symfony Serializer (поддержка групп, встраивание отношений, максимальная глубина...)
- Поддержка JWT и OAuth- Файлы и \DateTime, сериализация и десериализация
- Все полностью настраивается благодаря мощной системе событий и сильному ООП.
Сразу отмечу, что в Symfony можно создавать API через контроллер
php bin/console make:controller --no-template SuperApiController
и так же настроить валидацию и доступы через атрибуты
Код
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class SuperApiController extends AbstractController
{
#[Route('/super/api', name: 'app_super_api')]
#[IsGranted("ROLE_ADMIN")]
public function index(): JsonResponse
{
return $this->json([
'message' => 'Welcome to your new controller!',
'path' => 'src/Controller/SuperApiController.php',
]);
}
}
Но если можно не писать код, а использовать уже проверенный, почему бы не пойти такой дорогой?
После изучения этого материала ты узнаешь:
Как с помощью API platform создать CRUD и получить документацию swagger.
Добавим операцию регистрации пользователя и аутентификацию через токен.
Добавим доступы к операциям и позволим менять пароль через API только себе.
Устанавливаем API platform
composer require api
Создадим сущность пользователя
php bin/console make:user
И теперь, давай добавим просто атрибут [ApiResource] у этой сущности
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
#[ApiResource] //<-- Добавил только это
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
...
Вот и всё готово! Мы создали полноценное REST Api.
Заходи по ссылке http://localhost/api
И видим, что у нас появились методы для CRUD. По сути, мы написали одну строчку кода, добавив один атрибут, и уже имеем такой мощный функционал.

Отлично, движемся дальше.
Делаем метод регистарции пользователя
Для этого нам необходимо добавить поле токен к сущности пользователя
php bin/console make:entity User
добавим поле token
В самой сущности, добавим поле plainPassword, которой добавим атрибут
Сущность User
use Symfony\Component\Serializer\Annotation\SerializedName;
//....
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
//...
#[SerializedName('password')]
private $plainPassword;
/**
* @return mixed
*/
public function getPlainPassword()
{
return $this->plainPassword;
}
/**
* @param mixed $plainPassword
*/
public function setPlainPassword($plainPassword): void
{
$this->plainPassword = $plainPassword;
}
/**
* @see UserInterface
*/
public function eraseCredentials()
{
// If you store any temporary, sensitive data on the user, clear it here
$this->plainPassword = null; // <--- Раскоментируем эту строчку
}
}
Далее нам необходимо реализовать процессор состояний, который будет содержать логику хэширования пароля и добавления ролей пользователю по умолчанию.
Смотри документацию про Процессоры состояний
После чего мы сможем использовать этот процессор в операциях для создания пользователя и изменения пароля.
Поехали
php bin/console make:state-processor UserPasswordHasherProcessor
Регистрируем его в конфиге services.yaml
#services.yaml
services:
App\State\UserPasswordHasherProcessor:
bind:
'@api_platform.doctrine.orm.state.persist_processor'
Сам процессор будет выглядеть так.
UserPasswordHasherProcessor
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserPasswordHasherProcessor implements ProcessorInterface
{
public function __construct(private readonly ProcessorInterface $processor, private readonly UserPasswordHasherInterface $passwordHasher)
{
}
/**
* @param User $data
* @param Operation $operation
* @param array $uriVariables
* @param array $context
*
* @return mixed
* @throws \Exception
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
if (!$data->getPlainPassword()) {
return $this->processor->process($data, $operation, $uriVariables, $context);
}
$hashedPassword = $this->passwordHasher->hashPassword(
$data,
$data->getPlainPassword()
);
$data->setPassword($hashedPassword);
$data->eraseCredentials();
// Если это операция по созданию нового пользователя, то генерим токе и назначаем роль по умолчанию
if ($operation instanceof Post){
$data->setToken(bin2hex(random_bytes(60)));
$data->setRoles($data->getRoles());
}
return $this->processor->process($data, $operation, $uriVariables, $context);
}
}
Настраиваем операции
Пришло время, связать наш ресурс API с логикой, описанной в процессоре. Для этого добавим атрибут operations к сущности User и пропишем все необходимые операции в виде методов API.
Обрати внимание, я указал созданный нами процессор UserPasswordHasherProcessor в операциях Post() и Patch(). А так же, в операции пост добавил параметр uriTemplate со значением /registration, что изменит uri у этого метода с /api/users на /api/registration.
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Get;
...
#[ApiResource(
operations: [
new GetCollection(),
new Post(processor: UserPasswordHasherProcessor::class,
uriTemplate: '/registration'),
new Get(),
new Patch(processor: UserPasswordHasherProcessor::class),
new Delete(),
]
)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
Отлично, давай посмотрим на то, как сейчас выглядит swagger. И видим, то что раньше у нас были поля в разделе Example value, а сейчас пусто.

Все нормально!
Так как я переопределил, каждый метод в параметре operations, API Platform не понимает, какие поля ему нужно использовать для каждого метода как входные параметры, и какие поля будут отображены после успешной работы метода. Пришло время настроить сериализацию и нормализацию данных.
Группы сериализации и нормализации
Смотри документацию про Сериализацию
Для этого в атрибуте ApiResource в сущности User опишем параметры normalizationContext для чтения данных из сущности, и denormalizationContext для записи данных в сущность.
use Symfony\Component\Serializer\Annotation\Groups;
...
#[ApiResource(
normalizationContext: ['groups' => ['user:read']],
denormalizationContext: ['groups' => ['user:create', 'user:update']],
operations: [
А дальше, назначим эти группы для полей в сущности User. Так, чтобы для создания пользователя мы запрашивали только логин и пароль, а возвращали токен.
Код
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[Groups(['user:create', 'user:update'])]
#[ORM\Column(length: 180, unique: true)]
private ?string $email = null;
#[ORM\Column]
private array $roles = [];
/**
* @var string The hashed password
*/
#[ORM\Column]
private ?string $password = null;
#[ORM\Column(length: 255)]
#[Groups(['user:read'])]
private ?string $token = null;
#[Groups(['user:create', 'user:update'])]
#[SerializedName('password')]
private $plainPassword;
Посмотрим, как теперь выглядит в Swagger Example value в методе /api/registration. Обновим страничку с описанием и видим что, все так как и запланировали.
{
"email": "string",
"password": "string"
}
И в ответе мы видим, что будет возращен токен
{
"@context": "string",
"@id": "string",
"@type": "string",
"token": "string"
}
Ты не поверишь, но метод регистрации уже работает! Можно уже сейчас нажать кнопку execute и все сработает! Опа, ошибка вывалилась!
Ну конечно! А ты настроил соединение с БД и выполнил миграцию данных?
БД и миграции
Пришло время настроить подключение и заполнить БД.
#.env
DATABASE_URL="mysql://root:root@mysql:3306/app?serverVersion=8&charset=utf8mb4"
Создадим миграцию
php bin/console make:migration
И выполним её
php bin/console doctrine:migrations:migrate
Регистрируем пользователя через API
Заполняем данные в Swagger Example value в методе /api/registration и нажимаем execute
Это же amazing какой-то!
Кстати, метод Patch тоже работает. И в нем так же можно изменить два поля
{
"email": "string",
"password": "string"
}
И в ответ вернется значение поля token. Только токен не будет генерироваться вновь, так как в UserPasswordHasherProcessor, есть такое условие:
...
if ($operation instanceof Post){ // <- только для метода Post
$data->setToken(bin2hex(random_bytes(60)));
$data->setRoles($data->getRoles());
}
Авторизация
В swagger, сверху есть такая кнопка Authorize, нажав на которую, мы видим...пустое окно. Сейчас мы настроим наше API чтобы он мог авторизовывать пользователя через токен, а в swagger мы могли использовать этот токен.
Начнем с настройки API Platform. Добавим такой конфиг config/packages/api_platform.yaml
#config/packages/api_platform.yaml
api_platform:
# The title of the API.
title: 'Edu API'
# The description of the API.
description: 'Edu API description'
# The version of the API.
version: '0.0.1'
# Set this to false if you want Webby to disappear.
show_webby: false
mapping:
paths: ['%kernel.project_dir%/src/Entity']
patch_formats:
json: ['application/merge-patch+json']
swagger:
versions: [3]
api_keys:
Bearer:
name: AUTH-TOKEN
type: header
И теперь нажав на кнопку Authorize, появится форма, с полем AUTH-TOKEN куда можно вставить токен нашего пользователя. Значение которого можно вытащить из таблицы user.
Но пока, авторизацию не реализована у нас в API, и хоть swagger радостно сообщит, что авторизовался, и будет честно слать токен в заголовке, API его не воспримет.
Давай реализуем необходимый аутентификатор, и все настроим.
Выполним команду и выберем вариант 0 Empty authenticator и дадим название ApiTokenAuthenticator
php bin/console make:auth
What style of authentication do you want? [Empty authenticator]:
[0] Empty authenticator
[1] Login form authenticator
> ApiTokenAuthenticator
Теперь, реализуем в нем все необходимые методы:
ApiKeyAuthenticator
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class ApiKeyAuthenticator extends AbstractAuthenticator
{
protected const HEADER_AUTH_TOKEN = 'AUTH-TOKEN';
/**
* @inheritDoc
*/
public function supports(Request $request): ?bool
{
return $request->headers->has(self::HEADER_AUTH_TOKEN);
}
/**
* @inheritDoc
*/
public function authenticate(Request $request): Passport
{
$apiToken = $request->headers->get(self::HEADER_AUTH_TOKEN);
if (null === $apiToken) {
throw new CustomUserMessageAuthenticationException('Auth token not found (header: "{{ header }}")', [
'{{ header }}' => self::HEADER_AUTH_TOKEN,
]);
}
return new SelfValidatingPassport(new UserBadge($apiToken));
}
/**
* @inheritDoc
*/
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
/**
* @inheritDoc
*/
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
throw $exception;
}
}
Настроим UserProvider на работу с полем token и настроим firewall
#config/packages/security.yaml
security:
#....
providers:
app_user_provider:
entity:
class: App\Entity\User
property: token # <-- тут устанавливаем поле token
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
stateless: true # <-- добавляем stateless: true так как у нас не будут использоваться сессии
provider: app_user_provider
custom_authenticator: App\Security\ApiKeyAuthenticator # <-- регистрируем ApiKeyAuthenticator
#...
Вот теперь, наш API готов к авторизации пользователя. Только в текущий момент, все что мы сделали абсолютно бесполезно, так как у нас не настроены доступы.
Доступы и роли
Определим роли и разрешения
Давай представим, а как бы хотелось пользоваться текущим API.
Метод POST /api/registration должен быть доступен всем, чтобы пользователи свободно регистрировались.
Должен быть метод POST /api/login тоже должен быть доступен всем, и в ответ давать свежий токен для авторизации
Методы GET /api/users и /api/users/{id} доступно только залогиненым пользователям
Методы PATCH /api/users/{id} можно менять пароль только себе, доступно только залогиненым пользователям
Методы DELETE /api/users/{id} можно удалить только себя, доступно только залогиненым пользователям
Первый пункт, уже реализован.
Для пункта 2, давай сделаем процессор для метода login и добавим его в операции.
php bin/console make:state-processor UserLoginProcessor
Так же добавим метод findOneByLogin в UserRepository для нахождения пользователя по email
public function findOneByLogin($value): ?User
{
return $this->createQueryBuilder('u')
->andWhere('u.email = :val')
->setParameter('val', $value)
->getQuery()
->getOneOrNullResult();
}
Теперь добавим логику для процессора.
UserLoginProcessor
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserLoginProcessor implements ProcessorInterface
{
public function __construct(private UserRepository $repository, private UserPasswordHasherInterface $userPasswordEncoder)
{
}
/**
* @param User $data
* @param Operation $operation
* @param array $uriVariables
* @param array $context
*
* @return User
* @throws \Exception
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
$user = $this->repository->findOneByLogin($data->getEmail());
if ($user instanceof User) {
if (!$this->userPasswordEncoder->isPasswordValid($user, $data->getPlainPassword())) {
throw new AccessDeniedHttpException();
}
$user->setToken(bin2hex(random_bytes(60)));
$this->repository->save($user, true);
return $user;
}
throw new NotFoundHttpException();
}
}
Если служба автозапуска и автоконфигурации включена (они включены по умолчанию), то все готово!
# api/config/services.yaml
services:
# default configuration for services in this file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
В противном случае, если вы используете пользовательскую конфигурацию внедрения зависимостей, вам необходимо зарегистрировать соответствующую службу и добавить тег api_platform.state_processor.
# api/config/services.yaml
services:
# ...
App\State\UserLoginProcessor: ~
# Uncomment only if autoconfiguration is disabled
#tags: [ 'api_platform.state_processor' ]
Осталось создать новый метод для логина и настроить все доступы, как описано в пунктах 3-5
Настроим доступы
Для удовлетворения требований пункта 2, переходим к сущности User и добавим операцию для метода POST /api/login
Сущность User
use App\State\UserLoginProcessor;
...
#[ApiResource(
normalizationContext: ['groups' => ['user:read']],
denormalizationContext: ['groups' => ['user:create']],
operations: [
new GetCollection(),
new Post(processor: UserPasswordHasherProcessor::class, uriTemplate: '/registration'),
new Post(processor: UserLoginProcessor::class,
uriTemplate: '/login'
),
new Get(),
new Patch(),
new Delete(),
]
)]
Если взглянуть на swagger, то появился новый метод, который уже работает.
Теперь для пункта 3, наших требований, добавим параметр security для операций Get() и GetCollection()
Сущность User
use App\State\UserLoginProcessor;
...
#[ApiResource(
normalizationContext: ['groups' => ['user:read']],
denormalizationContext: ['groups' => ['user:create']],
operations: [
new GetCollection(security: "is_granted('ROLE_USER')"),
new Post(processor: UserPasswordHasherProcessor::class, uriTemplate: '/registration'),
new Post(processor: UserLoginProcessor::class,
uriTemplate: '/login'
),
new Get(security: "is_granted('ROLE_USER')"),
new Patch(),
new Delete(),
]
)]
Теперь, если вызвать эти операции через swagger, без установленного токена, получим ошибку 403 Доступ запрещен
И теперь, самое интересное - пункты 4 и 5.
Сущность User
use App\State\UserLoginProcessor;
...
#[ApiResource(
normalizationContext: ['groups' => ['user:read']],
denormalizationContext: ['groups' => ['user:create']],
operations: [
new GetCollection(security: "is_granted('ROLE_USER')"),
new Post(processor: UserPasswordHasherProcessor::class, uriTemplate: '/registration'),
new Post(processor: UserLoginProcessor::class,
uriTemplate: '/login'
),
new Get(security: "is_granted('ROLE_USER')"),
new Patch(security: "object == user",
securityMessage: 'Пароль можно менять только себе'),
new Delete(security: "object == user",
securityMessage: 'Удалять можно только себя'),
]
)]
Доступными переменными являются:
user
: текущий объект, вошедший в систему
object
: текущий класс ресурсов во время денормализации, текущий ресурс во время нормализации или коллекция ресурсов
previous_object
: (только после securityPostDenormalize
) клон объекта, до внесения изменений - это значение равно null
для операций создания нового объекта
request
(только на уровне ресурсов): текущий запрос
Проверки контроля доступа в атрибуте безопасности всегда выполняются перед этапом денормализации. Это означает, что для запросов PUT или PATCH объект содержит не значение, отправленное пользователем, а значения, хранящиеся в данный момент на уровне сохраняемости (persistence layer).
И ещё один момент - для операции Patch() сейчас отображаются поля
{
"email": "string",
"password": "string"
}
А хотелось бы настроить поля так, чтобы менять можно было только пароль.
И в результат, выдавать не token, а например email. Для таких целей, можно настроить группы на уровне операции и добавить их на соответствующие поля в сущности.
В итоге сущность User выглядит так
use App\State\UserLoginProcessor;
...
#[ApiResource(
normalizationContext: ['groups' => ['user:read']],
denormalizationContext: ['groups' => ['user:create']],
operations: [
new GetCollection(security: "is_granted('ROLE_USER')"),
new Post(processor: UserPasswordHasherProcessor::class, uriTemplate: '/registration'),
new Post(processor: UserLoginProcessor::class,
uriTemplate: '/login'
),
new Get(security: "is_granted('ROLE_USER')"),
new Patch(security: "object == user",
securityMessage: 'Пароль можно менять только себе',
normalizationContext: ['groups' => ['user:passwordRead']],
denormalizationContext: ['groups' => ['user:passwordUpdate']],
),
new Delete(security: "object == user",
securityMessage: 'Удалять можно только себя'),
]
)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[Groups(['user:create', 'user:passwordRead'])]
#[ORM\Column(length: 180, unique: true)]
private ?string $email = null;
#[ORM\Column]
private array $roles = [];
/**
* @var string The hashed password
*/
#[ORM\Column]
private ?string $password = null;
#[ORM\Column(length: 255)]
#[Groups(['user:read'])]
private ?string $token = null;
#[Groups(['user:create', 'user:passwordUpdate'])]
#[SerializedName('password')]
private $plainPassword;
Вот так, у нас получился гибкий функционал. И, обрати внимание как мало кода было написано! В следующих материалах, расскажу про другие фичи этой библиотеки.
Надеюсь всё было понятно и полезно, и данный материал поможет создавать классные сервисы быстро и безопасно.
Заключение
Вы можете найти код из этого материала на GitHub.
Перевод документации на русский тут
Буду рад вашим добрым советам, замечаниям и здоровой критики.
Отличного дня!