Создание блога на Symfony

Установка Symfony

$ composer create-project symfony/skeleton symfony-blog
$ cd symfony-blog

Установка необходимых пакетов

$ composer require annotations orm-pack serializer
$ composer require maker-bundle --dev

Настройка подключения к БД

# настройка логина, пароля и имя БД
$ nano .env

# настройка версии mysql и кодировки
$ nano config/packages/doctrine.yaml

Установка API Platform и EasyAdmin

$ composer require api admin

Создание контроллера для главной страницы

$ php bin/console make:controller

Choose a name for your controller class (e.g. BravePizzaController):
> HomeController

created: src/Controller/HomeController.php
created: templates/home/index.html.twig
$ nano src/Controller/HomeController.php
# src/Controller/HomeController.php

# меняем роут
/**
 * @Route("/", name="app_home")
 */

Настройка авторизации

Security (Symfony Docs)

$ composer require symfony/security-bundle
# создание сущности для пользователей
$ php bin/console make:user

The name of the security user class (e.g. User) [User]:
> User

Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
> yes

Enter a property name that will be the unique "display" name for the user (e.g.
email, username, uuid [email]
> email

Does this app need to hash/check user passwords? (yes/no) [yes]:
> yes

created: src/Entity/User.php
created: src/Repository/UserRepository.php
updated: src/Entity/User.php
updated: config/packages/security.yaml
# миграции БД
$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate
# форма авторизации
$ php bin/console make:auth

What style of authentication do you want? [Empty authenticator]:
 [0] Empty authenticator
 [1] Login form authenticator
> 1

The class name of the authenticator to create (e.g. AppCustomAuthenticator):
> LoginFormAuthenticator

Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
> SecurityController

Do you want to generate a '/logout' URL? (yes/no) [yes]:
> yes

created: src/Security/LoginFormAuthenticator.php
updated: config/packages/security.yaml
created: src/Controller/SecurityController.php
created: templates/security/login.html.twig
# настройка переадресации при успешной авторизации
# src/Security/LoginFormAuthenticator.php:89
# return new RedirectResponse($this->urlGenerator->generate('app_home'));

$ nano src/Security/LoginFormAuthenticator.php

Добавление пользователя

# Получить хеш строку для пароля:
php bin/console security:encode-password

Добавить в БД в таблицу user пользователя с полученным хешем. В ячейку user.roles вставить ["ROLE_ADMIN"]

JWT Token

LexikJWTAuthenticationBundle

$ composer require "lexik/jwt-authentication-bundle"

# создание ключей
# секретная фраза для генерации находится в .env JWT_PASSPHRASE=**********
$ mkdir -p 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

# настройка времени жизни токена
# необходимо добавить token_ttl: 3600, где 3600 - время жизни в секундах
$ nano config/packages/lexik_jwt_authentication.yaml
# настройка безопасности
$ nano config/packages/security.yaml
# config/packages/security.yaml
security:
    # ...

    firewalls:

        login:
            pattern:  ^/api/login
            stateless: true
            anonymous: true
            json_login:
                check_path:               /api/login_check
                success_handler:          lexik_jwt_authentication.handler.authentication_success
                failure_handler:          lexik_jwt_authentication.handler.authentication_failure

        api:
            pattern:   ^/api
            stateless: true
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator

    access_control:
        - { path: ^/api/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/api,       roles: IS_AUTHENTICATED_FULLY }
# настройка роута
$ nano config/routes.yaml
# config/routes.yaml
api_login_check:
    path: /api/login_check

JWT Refresh Token

JWTRefreshTokenBundle

$ composer require "gesdinet/jwt-refresh-token-bundle"
# настройка роута
$ nano config/routes.yaml
# config/routes.yaml
gesdinet_jwt_refresh_token:
    path:       /api/token/refresh
    controller: gesdinet.jwtrefreshtoken::refresh
# настройка безопасности
$ nano config/packages/security.yaml
# config/packages/security.yaml
    firewalls:
        refresh:
            pattern:  ^/api/token/refresh
            stateless: true
            anonymous: true
    # ...

    access_control:
        # ...
        - { path: ^/api/token/refresh, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        # ...
# ...
# миграции БД
$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate
# настройка времени жизни refresh токена
$ nano config/packages/gesdinet_jwt_refresh_token.yaml
# config/packages/gesdinet_jwt_refresh_token.yaml
gesdinet_jwt_refresh_token:
    ttl: 2592000

Создание сущностей для блога

Схема таблиц

$ php bin/console make:entity

После создания создаём и применяем миграции

# миграции БД
$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate

Настройка связей ManyToOne и OneToMany

# src/Entity/Article.php

/**
 * @ORM\ManyToOne(targetEntity="App\Entity\Category", inversedBy="articles")
 * @ORM\JoinColumn(nullable=false)
 */
private $category;

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

public function setCategory(Category $category): self
{
    $this->category = $category;

    return $this;
}
# src/Entity/Category.php

/**
 * @ORM\OneToMany(targetEntity="App\Entity\Article", mappedBy="category")
 */
private $articles;

public function __construct()
{
    $this->articles = new ArrayCollection();
}

public function __toString()
{
    return $this->name;
}

public function getArticles(): Collection
{
    return $this->articles;
}
# миграции БД
$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate

Admin: Ограничение доступа

$ nano config/packages/security.yaml
# config/packages/security.yaml
    # ...

    access_control:
        # ...
        - { path: ^/admin, roles: ROLE_ADMIN }
        # ...
# ...

Admin: Настройка

EasyAdminBundle

$ nano config/packages/easy_admin.yaml
# config/packages/easy_admin.yaml
easy_admin:
  site_name: "Blog"
  user:
    display_name: true
    display_avatar: true
    name_property_path: "name"
    avatar_property_path: "avatar"
  entities:
    # List the entity class name you want to manage
    User:
      class: App\Entity\User
      form:
        fields:
          - "email"
          - "name"
          - "password"
    Category:
      class: App\Entity\Category
      form:
        fields:
          - "name"
          - "slug"
    Article:
      class: App\Entity\Article
      form:
        fields:
          - "category"
          - "user"
          - "name"
          - "slug"
          - "content"
          - "created_at"
          - "is_active"
    Comment:
      class: App\Entity\Comment
      form:
        fields:
          - "message"
          - "created_at"
          - "is_active"

Admin: Загрузка изображений

$ composer require vich/uploader-bundle

# настройка
$ nano config/packages/vich_uploader.yaml
# config/packages/vich_uploader.yaml
vich_uploader:
  db_driver: orm

  mappings:
    article_image:
      uri_prefix: /images
      upload_destination: "%kernel.project_dir%/public/images"
      namer: Vich\UploaderBundle\Naming\UniqidNamer
    avatar_image:
      uri_prefix: /images/avatar
      upload_destination: "%kernel.project_dir%/public/images/avatar"
      namer: Vich\UploaderBundle\Naming\UniqidNamer
# src/Entity/Article
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Annotation as Vich;
# ...

/**
 # ...
 * @Vich\Uploadable
 */
class Article
{
    # ...

    /**
     * @Vich\UploadableField(mapping="article_image", fileNameProperty="image")
     * 
     * @var File
     */
    private $imageFile;

    /**
     * If manually uploading a file (i.e. not using Symfony Form) ensure an instance
     * of 'UploadedFile' is injected into this setter to trigger the update. If this
     * bundle's configuration parameter 'inject_on_load' is set to 'true' this setter
     * must be able to accept an instance of 'File' as the bundle will inject one here
     * during Doctrine hydration.
     *
     * @param File|\Symfony\Component\HttpFoundation\File\UploadedFile $imageFile
     */
    public function setImageFile(?File $imageFile = null): void
    {
        $this->imageFile = $imageFile;

        // if (null !== $imageFile) {
        // It is required that at least one field changes if you are using doctrine
        // otherwise the event listeners won't be called and the file is lost
        // $this->updatedAt = new \DateTimeImmutable();
        // }
    }

    public function getImageFile(): ?File
    {
        return $this->imageFile;
    }
}

# config/packages/easy_admin.yaml
easy_admin:
    # ...
    entities:
        # ...
        Article:
            class: App\Entity\Article
                form:
                    fields:
                        # ...
                        - {
                            property: "imageFile",
                            type: 'Vich\UploaderBundle\Form\Type\VichFileType',
                          }

Менеджер файлов

$ mkdir public/uploads

$ composer require artgris/filemanager-bundle

# настройка роута
$ nano config/routes.yaml
# config/routes.yaml
artgris_bundle_file_manager:
  resource: "@ArtgrisFileManagerBundle/Controller"
  type:     annotation
  prefix:   /manager
# настройка менеджера
$ nano config/packages/config.yml
# config/packages/config.yml
framework:
  translator: { fallbacks: [ "en" ] }
artgris_file_manager:
  web_dir: public
  conf:
    default:
      dir: "../public/uploads"
# ограничение доступа
# config/packages/security.yaml
    # ...

    access_control:
        # ...
        - { path: ^/manager, roles: ROLE_ADMIN }
        # ...
# ...

Менеджер доступен по адресу: /manager/?conf=default

API: Настройка сортировки по умолчанию

API Platform: Overriding Default Order

Сортировка комментариев по дате публикации

$ nano src/Entity/Comment.php
# src/Entity/Comment.php
/**
 * @ApiResource(
 *     attributes={
 *         "order"={"created_at": "DESC"}
 *     },
 * .....
 * )
 */

API: Настройка вложенных ресурсов

API Platform: Subresources Для получения вложенных элементов через роут /api/articles/{id}/comments:

# src/Entity/Article.php

# ...
use ApiPlatform\Core\Annotation\ApiSubresource;

# ...
/**
 # ...
 * @ApiSubresource()
 */
private $comments;

API: Настройка операций

API Platform: Operations

# src/Entity/User
use ApiPlatform\Core\Annotation\ApiResource;
# ...

/**
 * @ApiResource(
 *     itemOperations={
 *         "get",
 *         "put"
 *     },
 *     collectionOperations={}
 * )
 # ...
 */
class User implements UserInterface
{
    # ...
}
# src/Entity/Category
use ApiPlatform\Core\Annotation\ApiResource;
# ...

/**
 * @ApiResource(
 *     itemOperations={
 *         "get"={
 *             "normalization_context"={
 *                 "groups"={"get-category-with-articles"}
 *             }
 *         }
 *     },
 *     collectionOperations={
 *         "get"={
 *             "normalization_context"={
 *                 "groups"={"get-category-with-articles"}
 *             }
 *         }
 *     }
 * )
 # ...
 */
class Category
{
    # ...
}
# src/Entity/Article
use ApiPlatform\Core\Annotation\ApiResource;
# ...

/**
 * @ApiResource(
 *     itemOperations={
 *         "get"
 *     },
 *     collectionOperations={
 *         "get"
 *     }
 * )
 # ...
 */
class Article
{
    # ...
}
# src/Entity/Comment
use ApiPlatform\Core\Annotation\ApiResource;
# ...

/**
 * @ApiResource(
 *     attributes={
 *         "order"={"created_at": "DESC"}
 *     },
 *     subresourceOperations={
 *         "api_articles_comments_get_subresource"={
 *             "method"="GET",
 *             "normalization_context"={
 *                 "groups"={"get-comment-with-user"}
 *             }
 *         },
 *     },
 *     itemOperations={
 *         "get"
 *     },
 *     collectionOperations={
 *         "post"={
 *             "denormalization_context"={
 *                 "groups"={"post-new-comment"}
 *             }
 *         }
 *     }
 * )
 # ...
 */
class Comment
{
    # ...
}

В результате имеем следующее:

API Platform

API: Ограничение доступа

API Platform: Security

# src/Entity/Comment
use ApiPlatform\Core\Annotation\ApiResource;
# ...

/**
 * @ApiResource(
 *     attributes={
 *         "order"={"created_at": "DESC"}
 *     },
 *     subresourceOperations={
 *         "api_articles_comments_get_subresource"={
 *             "method"="GET",
 *             "normalization_context"={
 *                 "groups"={"get-comment-with-user"}
 *             }
 *         },
 *     },
 *     itemOperations={
 *         "get"
 *     },
 *     collectionOperations={
 *         "post"={
 *             "normalization_context"={
 *                 "groups"={"get-comment-with-user"}
 *             },
 *             "access_control"="is_granted('ROLE_USER')"
 *         }
 *     },
 *     denormalizationContext={
 *         "groups"={"post"}
 *     }
 * )
 # ...
 */
class Comment
{
    # ...
}
# src/Entity/User
use ApiPlatform\Core\Annotation\ApiResource;
# ...

/**
 * @ApiResource(
 *     itemOperations={
 *         "get",
 *         "put"={"access_control"="is_granted('ROLE_USER') and object == user"}
 *     },
 *     collectionOperations={}
 * )
 # ...
 */
class User implements UserInterface
{
    # ...
}

API: Фильтрация по умолчанию

API: Получение информации о текущем пользователе

В HomeController добавим экшен:

    /**
     * @Route("/api/user_current", name="api_user_current")
     */
    public function currentUser(): Response
    {
        $user = $this->get('security.token_storage')->getToken()->getUser();
        $response = new Response(
            json_encode([
                'id' => $user->getId(),
                'name' => $user->getName(),
                'avatar' => $user->getAvatar()
                ]),
            Response::HTTP_OK,
            ['content-type' => 'application/json']
        );
        return $response;
    }

Для получения информации о текущем пользователе необходимо перейти по /api/user_current с токеном в заголовках

API: Добавление комментариев

API: Загрузка изображений

На главную