19 августа 2025 г.
Установка Symfony
symfony new symfony_api_app --webapp
cd symfony_api_app
Где --webapp, указывает, что нужно установить все основные пакеты, необходимые для веб-разработки.
Установка необходимых пакетов для API и JWT аутентификации
composer require symfony/security-bundle
composer require symfony/maker-bundle --dev
composer require lexik/jwt-authentication-bundle
composer require doctrine/orm doctrine/annotations
composer require symfony/serializer symfony/validator
Где:
- symfony/security-bundle: Основа для безопасности в Symfony.
- symfony/maker-bundle: Помогает генерировать boilerplate код (только для разработки).
- lexik/jwt-authentication-bundle: Реализует аутентификацию на основе JWT (JSON Web Token).
- doctrine/orm doctrine/annotations: Для работы с базой данных и сущностями.
- symfony/serializer symfony/validator: Для сериализации данных в JSON и валидации входных данных.
Настройка базы данных
Установим Doctrine Migrations:
composer require doctrine/doctrine-migrations-bundle
Отредактируем файл .env в корне вашего проекта и настроим параметры подключения к базе данных (MySQL):
DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7"
Создадим базу данных (если она еще не существует):
php bin/console doctrine:database:create
Настройка JWT ключей
mkdir config/jwt
openssl genpkey -out config/jwt/private.pem -aes256 -algorithm RSA -pkeyopt rsa_keygen_bits:4096
openssl pkey -in config/jwt/private.pem -out config/jwt/public.pem -pubout
PEM pass phrase (парольная фраза) берётся из файла .env (JWT_PASSPHRASE).
Создание User
Используйте maker-bundle для создания сущности User:
php bin/console make:entity User
Укажим, какие поля нужны:
- email (string, length 180, unique)
- password (string)
- roles (json)
Отредактируем файл (src/Entity/User.php):
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180)]
private ?string $email = null;
/**
* @var list<string> The user roles
*/
#[ORM\Column]
private array $roles = [];
/**
* @var string The hashed password
*/
#[ORM\Column]
private ?string $password = null;
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;
}
/**
* A visual identifier that represents this user.
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
/**
* @param list<string> $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;
}
/**
* Ensure the session doesn't contain actual password hashes by CRC32C-hashing them, as supported since Symfony 7.3.
*/
public function __serialize(): array
{
$data = (array) $this;
$data["\0".self::class."\0password"] = hash('crc32c', $this->password);
return $data;
}
#[\Deprecated]
public function eraseCredentials(): void
{
// @deprecated, to be removed when upgrading to Symfony 8
}
}
Отредактируем файл (src/Repository/UserRepository.php):
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
}
Создание миграции и обновление базы данных:
php bin/console make:migration
php bin/console doctrine:migrations:migrate
Создадим команду консоли, чтобы упростить создание пользователей с хэшированными паролями:
php bin/console make:command CreateUserCommand
Отредактируем файл (src/Command/CreateUserCommand.php):
<?php
namespace App\Command;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(
name: 'app:create-user',
description: 'Creates a new user account',
)]
class CreateUserCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager,
private UserPasswordHasherInterface $passwordHasher
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('email', InputArgument::REQUIRED, 'User email')
->addArgument('password', InputArgument::REQUIRED, 'User password');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$email = $input->getArgument('email');
$plainPassword = $input->getArgument('password');
// Проверяем, существует ли пользователь
$existingUser = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]);
if ($existingUser) {
$output->writeln('User with this email already exists!');
return Command::FAILURE;
}
// Создаем нового пользователя
$user = new User();
$user->setEmail($email);
$user->setRoles(['ROLE_USER']);
// Хешируем пароль
$hashedPassword = $this->passwordHasher->hashPassword($user, $plainPassword);
$user->setPassword($hashedPassword);
// Сохраняем пользователя
$this->entityManager->persist($user);
$this->entityManager->flush();
$output->writeln('User created successfully!');
$output->writeln('Email: ' . $email);
return Command::SUCCESS;
}
}
Внесём нашу команду (config/services.yaml):
parameters:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Command\CreateUserCommand:
tags: [console.command]
Создадим нового пользователя:
php bin/console app:create-user test@test.ru 123456
Создание контроллера аутентификации
php bin/console make:controller AuthController
Отредактируем файл (src/Controller/AuthController.php):
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
class AuthController extends AbstractController
{
#[Route('/api/login', name: 'api_login', methods: ['POST'])]
public function login(): JsonResponse
{
return new JsonResponse([
'message' => 'Login should be handled by JSON login',
'status' => 'error'
], 200);
}
}
Внесём изменения в файл security.yaml (config/packages/security.yaml):
security:
password_hashers:
App\Entity\User:
algorithm: auto
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
login:
pattern: ^/api/login
stateless: true
json_login:
check_path: /api/login
username_path: email
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
api:
pattern: ^/api
stateless: true
jwt: ~
access_control:
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
Создание Article
php bin/console make:entity Article
Укажем, какие поля нужны:
- title (string, length 255)
- description (text)
Создадим контроллер:
php bin/console make:crud Article
Или так:
php bin/console make:controller Article
Отредактируем файл (src/Controller/ArticleController.php):
<?php
namespace App\Controller;
use App\Entity\Article;
use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
#[Route('/api/articles')]
class ArticleController extends AbstractController
{
private $serializer;
public function __construct(SerializerInterface $serializer)
{
$this->serializer = $serializer;
}
#[Route('', name: 'article_list', methods: ['GET'])]
public function index(ArticleRepository $articleRepository): JsonResponse
{
$articles = $articleRepository->findAll();
$data = $this->serializer->serialize($articles, 'json');
return new JsonResponse($data, 200, [], true);
}
#[Route('/{id}', name: 'article_show', methods: ['GET'])]
public function show(int $id, EntityManagerInterface $entityManager): JsonResponse
{
$article = $entityManager->getRepository(Article::class)->find($id);
if (!$article) {
return $this->json([
'message' => 'Article not found'
], Response::HTTP_NOT_FOUND);
}
$data = $this->serializer->serialize($article, 'json');
return new JsonResponse($data, 200, [], true);
}
#[Route('', name: 'article_create', methods: ['POST'])]
public function create(Request $request, EntityManagerInterface $entityManager): JsonResponse
{
$data = json_decode($request->getContent(), true);
$article = new Article();
$article->setTitle($data['title']);
$article->setDescription($data['description'] ?? '');
$entityManager->persist($article);
$entityManager->flush();
$responseData = $this->serializer->serialize($article, 'json');
return new JsonResponse($responseData, 201, [], true);
}
#[Route('/{id}', name: 'article_update', methods: ['PUT'])]
public function update(Request $request, int $id, EntityManagerInterface $entityManager): JsonResponse
{
$article = $entityManager->getRepository(Article::class)->find($id);
if (!$article) {
return $this->json([
'message' => 'Article not found'
], Response::HTTP_NOT_FOUND);
}
$data = json_decode($request->getContent(), true);
if (isset($data['title'])) {
$article->setTitle($data['title']);
}
if (isset($data['description'])) {
$article->setDescription($data['description']);
}
$entityManager->flush();
$responseData = $this->serializer->serialize($article, 'json');
return new JsonResponse($responseData, 200, [], true);
}
#[Route('/{id}', name: 'article_delete', methods: ['DELETE'])]
public function delete(int $id, EntityManagerInterface $entityManager): JsonResponse
{
$article = $entityManager->getRepository(Article::class)->find($id);
if (!$article) {
return $this->json([
'message' => 'Article not found'
], Response::HTTP_NOT_FOUND);
}
$entityManager->remove($article);
$entityManager->flush();
return $this->json([
'message' => 'Article deleted successfully'
], Response::HTTP_OK);
}
#[Route('/{id}', name: 'article_patch', methods: ['PATCH'])]
public function patch(Request $request, int $id, EntityManagerInterface $entityManager): JsonResponse
{
$article = $entityManager->getRepository(Article::class)->find($id);
if (!$article) {
return $this->json([
'message' => 'Article not found'
], Response::HTTP_NOT_FOUND);
}
$data = json_decode($request->getContent(), true);
if (isset($data['title'])) {
$article->setTitle($data['title']);
}
if (isset($data['description'])) {
$article->setDescription($data['description']);
}
$entityManager->flush();
$responseData = $this->serializer->serialize($article, 'json');
return new JsonResponse($responseData, 200, [], true);
}
}
Создание миграции и обновление базы данных:
php bin/console make:migration
php bin/console doctrine:migrations:migrate
Тестирование API
Запускаем наш API:
symfony server:start
Сервер запуститься по адресу http://127.0.0.1:8000.
Если сервер запускали до этого, то сперва:
symfony server:stop
Для тестирования API можно использовать:
Получение токена:
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@test.ru",
"password": "123456"
}'
Получение всех статей:
curl -X GET http://localhost:8000/api/articles \
-H "Authorization: Bearer <your_token>"
Создание новой статьи:
curl -X POST http://localhost:8000/api/articles \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your_token>" \
-d '{
"title": "Новая статья",
"description": "Описание новой статьи"
}'
Получение конкретной статьи:
curl -X GET http://localhost:8000/api/articles/1 \
-H "Authorization: Bearer <your_token>"
Обновление статьи:
curl -X PUT http://localhost:8000/api/articles/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your_token>" \
-d '{
"title": "Обновленная статья",
"description": "Обновленное описание статьи"
}'
Частичное обновление статьи:
curl -X PATCH http://localhost:8000/api/articles/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your_token>" \
-d '{"title": "Только заголовок изменен"}'
Удаление статьи:
curl -X DELETE http://localhost:8000/api/articles/1 \
-H "Authorization: Bearer <your_token>"
Наш API для работы со статьями на Symfony готов и протестирован!