Chapter 05

ユーザーのCRUDを実装

たつきち
たつきち
2022.07.29に更新

この章に対応するコミット

デモアプリは日本語と英語に対応するためすべての文字列リテラルを翻訳しているので、コミットの内容は本文の解説と若干異なります。

ユーザーのCRUDを実装

ユーザーログインはできるようになったので、続けてここでユーザーのCRUDを実装してしまいます。

CRUDの雛形は make:crud コマンドで自動生成できますが、僕の場合はそこから結構変更するので、だいたいいつも既存のCRUDをコピペして修正する感じで作っています。

フォーム

先にFormTypeを作ってしまいます。

普通のエンティティなら追加用と編集用を兼ねるFormTypeを1つ作ればよいことが多いですが、ユーザーに関しては

  • ユーザー追加用( plainPassword を必須項目として持つ)
  • ユーザー編集用( plainPassword を持たない)
  • パスワード変更用(現在のパスワードと新しいパスワードだけを入力する、エンティティに紐づかないFormType)

の3つを作ります。

ユーザー追加用のFormType

class UserType extends AbstractType
{
    private RoleManager $rm;
    private TranslatorInterface $translator;

    public function __construct(RoleManager $rm, TranslatorInterface $translator)
    {
        $this->rm = $rm;
        $this->translator = $translator;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        // 閲覧権限は全員が必ず持つので選択肢に含めない
        $roles = array_filter($this->rm->getReachableRoles(), fn(string $role) => $role !== 'ROLE_ALLOWED_TO_VIEW');

        $builder
            ->add('email', EmailType::class, [
                'label' => 'メールアドレス',
                'attr' => [
                    'autofocus' => true,
                ],
            ])
            ->add('plainPassword', PasswordType::class, [
                'label' => 'パスワード',
            ])
            ->add('roles', ChoiceType::class, [
                'required' => false,
                'label' => 'アクセス権限',
                'choices' => array_combine($roles, $roles),
                'multiple' => true,
                'placeholder' => '',
                'attr' => [
                    'data-placeholder' => '選択してください',
                    'data-allow-clear' => true,
                    'class' => 'w-100',
                ],
            ])
            ->add('displayName', TextType::class, [
                'required' => false,
                'label' => '表示名',
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver
            ->setDefaults([
                'data_class' => User::class,
                'validation_groups' => ['registration'],
            ])
        ;
    }
}

'validation_groups' => ['registration'] をセットしているのは、あとで ユーザー追加時のみ plainPassword 項目を必須にする ためです。頭の片隅に置いておいてください。

また、 RoleManager というサービスがいきなり登場していますが、これは自作のサービスで、アプリ内でどんなROLEが定義されていてどういう上下関係があるかなどを管理するためのものです。

あまりいい設計ではない気もしますが、今のところ僕はこれが一番やりやすいです😅

class RoleManager
{
    private array $roles;
    private Security $security;
    private RoleHierarchyInterface $roleHierarchy;

    public function __construct(Security $security, RoleHierarchyInterface $roleHierarchy)
    {
        // アプリ内で定義済みのROLEのリスト
        $this->roles = [
            'ROLE_ALLOWED_TO_ADMIN',
            'ROLE_ALLOWED_TO_EDIT_USER',
            'ROLE_ALLOWED_TO_EDIT',
            'ROLE_ALLOWED_TO_VIEW',
        ];

        $this->security = $security;
        $this->roleHierarchy = $roleHierarchy;
    }

    // 指定されたユーザーの保有しているROLEをリストで返す
    public function getReachableRoles(UserInterface $user = null): array
    {
        $user = $user ?? $this->security->getUser();

        return array_filter($this->roles, fn(string $role) => $this->isGranted($user, $role));
    }

    // 指定されたユーザーが特定のROLEを保有しているかどうかを判定する
    public function isGranted(UserInterface $user, string $targetRole): bool
    {
        return in_array($targetRole, $this->roleHierarchy->getReachableRoleNames($user->getRoles()));
    }
}

ユーザー編集用のFormType

class UserEditType extends UserType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        parent::buildForm($builder, $options);

        $builder
            ->remove('plainPassword')
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);

        $resolver->setDefaults([
            'validation_groups' => [],
        ]);
    }
}

先ほど作った UserType を継承して、 plainPassword 項目だけを削除しています。

また、validation_groups を空にしている点にも注目です。これは、ユーザー編集時には plainPassword 項目を必須扱いにしないための対応です。

パスワード変更用のFormType

class UserChangePasswordType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('oldPassword', PasswordType::class, [
                'label' => '現在のパスワード',
                'attr' => [
                    'autofocus' => true,
                ],
                'constraints' => [
                    new Assert\NotBlank(),
                    new SecurityAssert\UserPassword([
                        'message' => '現在のパスワードが正しくありません',
                    ]),
                ],
            ])
            ->add('newPassword', PasswordType::class, [
                'label' => '新しいパスワード',
                'constraints' => [
                    new Assert\NotBlank(),
                ],
            ])
        ;
    }
}

これはエンティティに紐づかないFormTypeなので、バリデーションはエンティティの @Assert アノテーションを使うのではなくフォーム項目自体に直接設定します。

エンティティ

エンティティにバリデーションをセットします。

  /**
   * @ORM\Entity(repositoryClass=UserRepository::class)
   * @ORM\EntityListeners({UserListener::class})
+  * @UniqueEntity(fields="email", message="そのメールアドレスはすでに登録されています", groups={"Default", "registration"})
   */
  class User implements UserInterface
  {
      // ...
  
      /**
       * @ORM\Column(type="string", length=180, unique=true)
+      *
+      * @Assert\NotBlank()
+      * @Assert\Email()
       */
      public ?string $email = null;
  
      // ...
      
+     /**
+      * @Assert\NotBlank(groups={"registration"})
+      */
      public ?string $plainPassword = null;
  
      // ...
  }

$plainPassword@Assert\NotBlank(groups={"registration"}) をつけることで、 ユーザー追加時のみ必須 にしています。

また、$email はもともと @ORM\Column(type="string", length=180, unique=true) によってDBレイヤーではユニーク制約がセットされていましたが、このままだと重複するメールアドレスを入力したときにフォームのエラーにならずにDBレイヤーのエラーになって例外がキャッチされずにアプリが終了してしまいます。

なので、@UniqueEntity を使ってちゃんとアプリレイヤーでエラーハンドリングするようにしています。

groups={"Default", "registration"} は、対象のバリデーショングループの指定です。

ユーザー追加用のFormTypeは 'validation_groups' => ['registration'] 、ユーザー編集用のFormTypeは 'validation_groups' => [] と設定したので、この両方に対して @UniqueEntity を効かせるためには、 Defaultregistration を対象グループとする必要があります。

バリデーショングループが指定されていない場合はデフォルトで Default グループに属します。(公式ドキュメント

コントローラ

次にコントローラに各種アクションを実装していきます。

まず、 EntityManagerUserRepository はどうせいろんなアクションメソッドから使うのでコンストラクタで注入しておきます。

public function __construct(EntityManagerInterface $em, UserRepository $repository)
{
    $this->em = $em;
    $this->repository = $repository;
}

あとは、 index new show edit changePassword delete という6つのアクションを適切に実装します。

/**
 * @Route("/", name="index", methods={"GET"})
 */
public function index()
{
    return $this->render('user/index.html.twig', [
        'users' => $this->repository->findAll(),
    ]);
}
/**
 * @Route("/new", name="new", methods={"GET", "POST"})
 * @IsGranted("ROLE_ALLOWED_TO_EDIT_USER")
 */
public function new(Request $request)
{
    $form = $this->createForm(UserType::class, $user = new User());
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $this->em->persist($user);
        $this->em->flush();

        $this->addFlash('success', 'ユーザーの追加が完了しました。');

        return $this->redirectToRoute('user_index');
    }

    return $this->render('user/index.html.twig', [
        'user' => $user,
        'form' => $form->createView(),
    ]);
}
/**
 * @Route("/{id}", name="show", methods={"GET"})
 */
public function show(User $user)
{
    return $this->render('user/index.html.twig', [
        'user' => $user,
    ]);
}
/**
 * @Route("/{id}/edit", name="edit", methods={"GET", "POST"})
 * @IsGranted("ROLE_ALLOWED_TO_EDIT_USER")
 */
public function edit(Request $request, User $user)
{
    $form = $this->createForm(UserEditType::class, $user);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $this->em->flush();

        $this->addFlash('success', 'ユーザーの編集が完了しました。');

        return $this->redirectToRoute('user_show', ['id' => $user->getId()]);
    }

    return $this->render('user/index.html.twig', [
        'user' => $user,
        'form' => $form->createView(),
    ]);
}
/**
 * @Route("/{id}/change_password", name="change_password", methods={"GET", "POST"})
 * @IsGranted("ROLE_ALLOWED_TO_EDIT_USER")
 */
public function changePassword(Request $request, User $user)
{
    $form = $this->createForm(UserChangePasswordType::class);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $user->plainPassword = $form->get('newPassword')->getData();

        $this->em->persist($user);
        $this->em->flush();

        $this->addFlash('success', 'パスワードの変更が完了しました。');

        return $this->redirectToRoute('user_show', ['id' => $user->getId()]);
    }

    return $this->render('user/index.html.twig', [
        'user' => $user,
        'form' => $form->createView(),
    ]);
}
/**
 * @Route("/{id}", name="delete", methods={"DELETE"})
 * @IsGranted("ROLE_ALLOWED_TO_EDIT_USER")
 */
public function delete(Request $request, User $user)
{
    if ($this->isCsrfTokenValid('delete'.$user->getId(), $request->request->get('_token'))) {
        $this->em->remove($user);

        try {
            $this->em->flush();
            $this->addFlash('success', 'ユーザーの削除が完了しました。');
        } catch (ForeignKeyConstraintViolationException $e) {
            $this->addFlash('danger', 'そのユーザーに紐づいているデータがあるため削除できません。');
        }
    }

    return $this->redirectToRoute('user_index');
}

delete アクションのみちょっと特殊なことをしていて、フロントからCSRFトークンを受け取って第三者からのリクエストでないことをチェックするようになっています。これは make:crud コマンドで作った雛形でも採用されている実装です。

フロント側からCSRFトークンをどうやって送るかについては後述します✋

また、更新系のアクションには @IsGranted("ROLE_ALLOWED_TO_EDIT_USER") をつけて ROLE_ALLOWED_TO_EDIT_USER というROLEを持っているユーザーしかアクセスできないようにしています。(普通のデータの編集権限とユーザーの編集権限は分けて管理したいのでこういうROLEを設けています)

ちなみに、意外と初心者の方がハマりやすいポイントですが、 @Route アノテーションでルーティングを設定する場合、リクエストURLが最初にマッチしたアクションメソッドが実行されるので、

/**
 * @Route("/{id}", name="show", methods={"GET"})
 */
public function show(User $user)
/**
 * @Route("/new", name="new", methods={"GET", "POST"})
 * @IsGranted("ROLE_ALLOWED_TO_EDIT_USER")
 */
public function new(Request $request)

という順番でアクションメソッドが定義されていたら、 /new へのリクエストは /{id} にマッチしてしまって「そんなIDのエンティティは見つかりません」というエラーになります。

@Route アノテーションでルーティングを設定する場合はコントローラクラス内のアクションメソッドの定義順が意味を持ちますので、気をつけましょう✋

ビュー

最後にビューの実装です。

コードを全部貼るとめちゃくちゃ長くなる上に、内容的には特別難しいことはしていないので、実際のtwigファイルの内容 を見てみてください🙏

特筆すべきこととしては、

ぐらいです。

動作確認

この時点で、以下のような感じで動作しています👍