このエントリーをはてなブックマークに追加

この記事は Symfony Advent Calendar 2017 15日目の記事です。

はじめに

弊社ではドキュメント管理ツールとして esa を利用しています。

サービス開始直後から3年間ずっとお世話になっていて、まあまあヘビーに使い込んでいるほうだと思っています。
そんな状況もあって、近頃はWeb UIでの操作だけでは痒いところに手が届かないケースがちょこちょこと出始めていました。

特に直近で困ったのが、記事を正規表現で検索したいということでした。
esaは 今のところ正規表現での検索はできない ので、大好きな symfony/console でパパッとCLIツールを作ってしまう ことにしました。

作ったもの

https://github.com/ttskch/esa-cli

esa-cliという名前ですが、いわゆるAPIをラップした感じのCLIツールではなく、少し複雑なアプリケーションとしての機能を提供するためのものです。今のところgrep(っぽいもの)しかありません。

sed(っぽいもの)も作って一括置換とかしたいな〜と思っているので、年末年始もしお暇な方がいらっしゃいましたらPRお待ちしています笑

インストール方法

動作にはPHP 7.1.3以上の環境が必要です。

$ composer create-project ttskch/esa-cli:@dev
$ cd esa-cli
$ cp app/parameters.php{.placeholder,}
$ vi app/parameters.php
<?php
// app/parameters.php
$container['parameters.team_name'] = 'esaチーム名';
$container['parameters.access_token'] = 'APIアクセストークン';
$container['parameters.esa_paging_limit'] = 10;   // 1度のAPIコールで何ページまで自動でページングするか

ページングについては こちらをご参照 ください。

$ ln -s $(pwd)/app/esa /usr/local/bin/esa

使い方

$ esa grep -h

grep サブコマンドのヘルプを確認できます。

image

  • -s [QUERY] で対象の記事群を絞り込む
  • <pattern> 部分で本文内をgrepしたいパターンを指定する
  • [QUERY] 部分の書式は esa公式 と同じ
  • -e オプションで正規表現での検索を有効にする
  • -e オプションなしの場合は単純な文字列の部分一致比較

実際の使い方は以下のような感じになります。

$ esa grep -s "in:Users/t-kanemoto/tmp title:テスト" -e "(あ|い|う){1,3}"
Users/t-kanemoto/tmp/テスト1:あ  
Users/t-kanemoto/tmp/テスト2:いい                                  
Users/t-kanemoto/tmp/テスト3:ううう
$ esa grep -s "in:Users/t-kanemoto/tmp title:テスト" -e "(あ|い|う){2}"
Users/t-kanemoto/tmp/テスト2:いい
Users/t-kanemoto/tmp/テスト3:ううう
$ esa grep -s "in:Users/t-kanemoto/tmp title:テスト" "ううう"
Users/t-kanemoto/tmp/テスト3:ううう

image

実装の要点

DIコンテナ

今回はDIコンテナに Pimple を使いました。
以下のような感じで必要なサービスを組み立てています。

https://github.com/ttskch/esa-cli/blob/master/app/container.php

いくつかのサービスが書かれていますが、symfony/console に関係するものは以下の2つです。

  • console サービス
    • ttskch/esa-cli という名称でコンソールアプリケーションを作成
    • grep_command サービスをコンソールアプリケーションに追加
  • grep_command サービス
    • GrepCommand クラスのインスタンスを作成
      • GrepCommandSymfony\Component\Console\Command\Command を継承している
<?php

// ...

$container = new Container();

require __DIR__ . '/parameters.php';

$container['console'] = function($container) {
    $console = new Application('ttskch/esa-cli');
    $console->add($container['grep_command']);

    return $console;
};

// ...

$container['grep_command'] = function($container) {
    return new GrepCommand($container['esa_proxy']);
};

// ...

return $container;

ブートストラップ

ブートストラップファイルは以下のようにDIコンテナから console サービスを取り出して起動するだけの簡素な内容です。

https://github.com/ttskch/esa-cli/blob/master/app/esa

#!/usr/bin/env php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
$container = require_once __DIR__ . '/container.php';
$container['console']->run();

grepコマンド本体

grep コマンド本体のコードは以下のような感じです。記事本文のMarkdownを愚直に preg_match しています。

結果の出力を スタイリングして、grepコマンドらしくマッチ箇所を赤太字にするようにしました。

https://github.com/ttskch/esa-cli/blob/master/src/Command/GrepCommand.php

<?php
// ...
class GrepCommand extends Command
{
    private $esaProxy;

    // ...

    /**
     * @param InputInterface $input
     * @param OutputInterface $output
     */
    protected function execute(InputInterface $input, OutputInterface $output): void
    {
        $query = $input->getOption('query');
        $posts = $this->esaProxy->getPosts($query, $input->getOption('force'));

        // convert pattern for preg_match
        $pattern = $input->getArgument('pattern');
        if ($input->getOption('regexp')) {
            $pattern = str_replace('\/', '/', $pattern);
            $pattern = str_replace('/', '\/', $pattern);
        } else {
            $pattern = preg_quote($pattern);
        }
        $pattern = sprintf('/%s/%s', $pattern, $input->getOption('ignore-case') ? 'i' : '');

        $matches = [];

        foreach ($posts as $post) {
            $fullName = sprintf('%s/%s', $post['category'], $post['name']);

            $i = 1;
            foreach (explode("\n", $post['body_md']) as $line) {
                $condition = preg_match($pattern, $line, $m);
                if ($input->getOption('invert-match')) {
                    $condition = !$condition;
                }

                if ($condition) {
                    $matches[] = [
                        'full_name' => $fullName,
                        'line_number' => $i,
                        'line' => $line,
                        'matched' => $m[0] ?? '',
                    ];
                }
                $i++;
            }
        }

        foreach ($matches as $match) {
            $fullName = $match['full_name'];
            $lineNumber = $input->getOption('line-number') ? $match['line_number'] . ':' : '';
            $line = str_replace($match['matched'], sprintf('<fg=red;options=bold>%s</>', $match['matched']), $match['line']);

            $output->writeln(sprintf('%s:%s%s', $fullName, $lineNumber, $line));
        }
    }
}

esa APIクライアント

symfony/console の使い方とは関係ない話ですが、polidog/esa-php をラップして結果をキャッシュしたりページングを隠蔽したりする Esa\Proxy というクラスを作ってあります。

esa APIでは

という制限があるため、コマンド実行時の -s [QUERY] で絞り込んだ結果の記事数があまりに大量だと、闇雲に自動でページングするとリクエスト数制限を簡単にオーバーしてしまいます。なので、自動ページング数の上限を定数で定義する 仕様にしてあります。

https://github.com/ttskch/esa-cli/blob/master/src/Esa/Proxy.php

<?php
// ...
class Proxy
{
    const CACHE_KEY_PREFIX = 'ttskch.esa_cli.esa.proxy';
    const CACHE_LIFETIME = 3600;

    private $esa;
    private $cache;
    private $pagingLimit;

    // ...

    /**
     * @param null|string $query
     * @param bool $force
     * @return array
     */
    public function getPosts(?string $query, $force = false): array
    {
        $cacheKey = self::CACHE_KEY_PREFIX . '.posts.' . hash('md5', $query);

        if ($force || !$posts = $this->cache->fetch($cacheKey)) {
            $posts = $this->mergePages('posts', ['q' => $query]);
            $this->cache->save($cacheKey, $posts, self::CACHE_LIFETIME);
        }

        return $posts;
    }

    /**
     * @param string $methodName
     * @param array $params
     * @param int $firstPage
     * @return array
     */
    public function mergePages(string $methodName, array $params = [], int $firstPage = 1): array
    {
        if (!in_array($methodName, ['posts'])) {
            throw new LogicException('Invalid method name.');
        }

        $results = [];
        $page = $firstPage;

        for ($i = 0; $i < $this->pagingLimit && !is_null($page); $i++) {
            $result = $this->esa->$methodName(array_merge($params, [
                'per_page' => 100,
                'page' => $page,
            ]));
            $results = array_merge($results, $result[$methodName]);
            $page = $result['next_page'];
        }

        return $results;
    }
}

おわりに

個人的に、CLIツールを作成するときに自力でUI(引数の扱い、出力の整形、ヘルプ、etc)を作り込むのが本当に面倒で不毛だと思っているので、symfony/console がその辺全部いい感じにやってくれるとコマンドの処理を書くことに集中できてとても幸せです。

今回のesa-cliでも、まともに実装したクラスは Esa\ProxyCommand\GrepCommand の2つだけでした :v:

とりあえずすぐに使いたかったのでテスト皆無なWIPプロダクトですが、symfony/console のおかげで便利なツールが簡単に作れて業務が捗りましたよ、というお話でした!(もしよければどなたかテスト書いてください)


カルテット開発部では、CLIツールもPHPで作成してしまうエンジニアを募集しています!


このエントリーをはてなブックマークに追加

Symfony Advent Calendar 2017 12日目の記事です。 枠が空いていたので3度めですが書いちゃいます。

REST APIで「フォームにsubmitした値がセットされない!」と思ったら

下記のように、ユーザーの編集を行うREST APIを書いたとします。

<?php


namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Class User
 *
 * @ORM\Entity
 * @package App\Entity
 */
class User
{
    /**
     * @var integer
     * @ORM\Id()
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer")
     */
    public $id;

    /**
     * @var string
     * @ORM\Column(type="string", length=255)
     */
    public $name = 'my name';

    /**
     * @var array|string[]
     * @ORM\Column(type="json_array")
     */
    public $roles = [];
}

フォーム

<?php

namespace App\Form\Type;

use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class UserUpdateType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name')
            ->add('roles', ChoiceType::class, [
                'choices' => [
                    'Admin' => 'ROLE_ADMIN',
                    'User' => 'ROLE_USER',
                    'Tester' => 'ROLE_TESTER',
                ],
                'multiple' => true,
                'expanded' => true,
            ])
        ;
    }

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

}

コントローラ

<?php


namespace App\Controller;

use App\Entity\User;
use App\Form\Type\UserUpdateType;
use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class UserController
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * @var FormFactoryInterface
     */
    private $formFactory;

    /**
     * @param EntityManagerInterface $em
     * @param FormFactoryInterface $formFactory
     */
    public function __construct(EntityManagerInterface $em, FormFactoryInterface $formFactory)
    {
        $this->em = $em;
        $this->formFactory = $formFactory;
    }

    /**
     * @ParamConverter(name="user", class="App:User")
     * @Route("/user/{id}")
     * @Method("PATCH")
     */
    public function updateAction(Request $request, User $user)
    {
        $form = $this->formFactory->createNamed('', UserUpdateType::class, $user, [
            'method' => 'PATCH',
        ]);
        $form->handleRequest($request);

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

            return new Response('success!');
        }

        return new Response('failed!');
    }
}

一見何の問題もありませんね。

では、このコントローラの機能テストを書いてみましょう。

App\Entity\User:
  user1:
    name: "user1"
    roles: ["ROLE_ADMIN", "ROLE_USER", "ROLE_TESTER"]
<?php

namespace App\Controller;

use App\Entity\User;
use Liip\FunctionalTestBundle\Test\WebTestCase;

class UserControllerTest extends WebTestCase
{
    public function setUp()
    {
        parent::setUp();

        $this->loadFixtureFiles([
            __DIR__.'/../fixtures/user.yml',
        ]);
    }

    public function test()
    {
        $client = static::createClient();
        $client->request('PATCH', '/user/1', [
            'name' => 'user1',
            'roles' => ['ROLE_ADMIN', 'ROLE_USER'],
        ]);

        $response = $client->getResponse();
        $this->assertTrue($response->isOk(), $response->getStatusCode());

        $em = $this->getContainer()->get('doctrine')->getManager();
        $em->clear();
        $updatedUser = $em->find(User::class, 1);
        $this->assertEquals(['ROLE_ADMIN', 'ROLE_USER'], $updatedUser->getRoles());
    }
}

実行してみます。

There was 1 failure:

1) App\Controller\UserControllerTest::test
Failed asserting that two arrays are equal.
--- Expected
+++ Actual
@@ @@
 Array (
     0 => 'ROLE_ADMIN'
     1 => 'ROLE_USER'
+    2 => 'ROLE_TESTER'

.../tests/Controller/UserControllerTest.php:33

FAILURES!
Tests: 3, Assertions: 8, Failures: 1.

$client->request()で送ったパラメータ上ではroleを減らしたはずなのに、保存されたエンティティには反映されていません。 なぜでしょうか?

<?php

namespace Symfony\Component\Form;

// ...

class Form implements FormInterface
{
    // ...
    
    public function submit($submittedData, $clearMissing = true)
    {
        // ...
        if ($this->config->getCompound()) {
            // ...
            foreach ($this->children as $name => $child) {
                $isSubmitted = array_key_exists($name, $submittedData);
                if ($isSubmitted || $clearMissing) {
                    $child->submit($isSubmitted ? $submittedData[$name] : null, $clearMissing);
                    unset($submittedData[$name]);
                    if (null !== $this->clickedButton) {
                        continue;
                    }
                    if ($child instanceof ClickableInterface && $child->isClicked()) {
                        $this->clickedButton = $child;
                        continue;
                    }
                    if (method_exists($child, 'getClickedButton') && null !== $child->getClickedButton()) {
                        $this->clickedButton = $child->getClickedButton();
                    }
                }
            }
        }    
        // ...
    }
    
    // ...
}

https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Form/Form.php#L571

なんと、値がsubmitされてない場合(multipleかつexpandedなChoiceTypeで値が減った場合)は $clearMissing=true でないとsubmitした値は無視されてしまいます。つまり、rolesの ROLE_TESTER がsubmitされなかったのは無視されたということです。 clearMissingにfalseを指定した覚えはありませんが、どこでfalseになったのでしょうか?

そもそも Form::submit() でなく Form::handleRequest() でsubmitしたはずなので、Form::handleRequest()を見ると…

<?php

namespace Symfony\Component\Form\Extension\HttpFoundation;

// ...
/**
 * A request processor using the {@link Request} class of the HttpFoundation
 * component.
 *
 * @author Bernhard Schussek <bschussek@gmail.com>
 */
class HttpFoundationRequestHandler implements RequestHandlerInterface
{
    // ...
    
    /**
     * {@inheritdoc}
     */
    public function handleRequest(FormInterface $form, $request = null)
    {
        // ...
        
        $form->submit($data, 'PATCH' !== $method);
    }
    
    // ...
}

https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationRequestHandler.php#L108

HTTPメソッドがPATCHの場合は強制的にclearMissingがfalseになっています。

解決策: PATCHを使わない

コントローラと機能テストをPATCHを使わない形に書き換えてみます。

<?php


namespace App\Controller;

use App\Entity\User;
use App\Form\Type\UserUpdateType;
use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class UserController
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * @var FormFactoryInterface
     */
    private $formFactory;

    /**
     * @param EntityManagerInterface $em
     * @param FormFactoryInterface $formFactory
     */
    public function __construct(EntityManagerInterface $em, FormFactoryInterface $formFactory)
    {
        $this->em = $em;
        $this->formFactory = $formFactory;
    }

    /**
     * @ParamConverter(name="user", class="App:User")
     * @Route("/user/{id}")
-     * @Method("PATCH")
+     * @Method("PUT")
    */
    public function updateAction(Request $request, User $user)
    {
        $form = $this->formFactory->createNamed('', UserUpdateType::class, $user, [
-            'method' => 'PATCH',
+            'method' => 'PUT',
        ]);
        $form->handleRequest($request);

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

            return new Response('success!');
        }

        return new Response('failed!');
    }
}
<?php

namespace App\Controller;

use App\Entity\User;
use Liip\FunctionalTestBundle\Test\WebTestCase;

class UserControllerTest extends WebTestCase
{
    public function setUp()
    {
        parent::setUp();

        $this->loadFixtureFiles([
            __DIR__.'/../fixtures/user.yml',
        ]);
    }

    public function test()
    {
        $client = static::createClient();
-        $client->request('PATCH', '/user/1', [
+        $client->request('PUT', '/user/1', [
            'name' => 'user1',
            'roles' => ['ROLE_ADMIN', 'ROLE_USER'],
        ]);

        $response = $client->getResponse();
        $this->assertTrue($response->isOk(), $response->getStatusCode());

        $em = $this->getContainer()->get('doctrine')->getManager();
        $em->clear();
        $updatedUser = $em->find(User::class, 1);
        $this->assertEquals(['ROLE_ADMIN', 'ROLE_USER'], $updatedUser->getRoles());
    }
}

テストを実行してみます。

$ vendor/bin/phpunit -c ./
PHPUnit 6.5.2 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 2.49 seconds, Memory: 30.00MB

OK (3 tests, 8 assertions)

通るようになりました!

余談: なぜそうなっているか?

私の推測ですが、フォームについて

  • エンティティに対してFormTypeは一つ
  • PATCHで部分更新したり、POST, PUTで全体更新したりする
  • PATCHのときはsubmit()される値が不完全だが、submitされてない値をクリアしたくない

という使い方が想定されているのではないでしょうか? 今回のサンプルコードのように、編集時の項目構成で作ったフォームにPATCHで値を送信する使い方はイレギュラーなのかもしれません。

まとめ

なぜかうまくいかないなーと思った時、ソースをどんどん読み込んでいけば解決できるのがオープンソースの良いところです。
Symfonyで開発していて「謎現象?!」と思ったら、怖がらずソースを読んでみましょう。(手動で追っていくのは骨が折れるのでIDEの定義ジャンプ機能を活用したいですね)


このエントリーをはてなブックマークに追加

この記事は Symfony Advent Calendar 2017 10日目の記事です。

はじめに

普段、CLIで利用するようなツールは、シェルスクリプトかGoで書くことが多いのですが、社内で利用するツールならPHPで作った方がメンテナンス性が上がってみんなハッピーかな?と思ったのが始まりです。
イメージ的にはComposerの様にPHPと意識しなくても使える感じが好ましいなと思ったので、Pharファイルで配布する形を目指すことにしました。

簡単なコンソールアプリを作成する

みんな大好き symfony/consoleコンポーネントを利用して、簡単なコンソールアプリケーションを作成します。

引数無しで実行したら、

Hello World!

引数に文字列foobarを渡したら

Hello Foobar!

みたいな感じのhelloコマンドを作成したいと思います。

Helloコマンド

<?php

namespace Qcmnagai\Hello;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class HelloCommand extends Command
{
    protected function configure()
    {
        $this
            ->setName('hello')
            ->addArgument('name', InputArgument::OPTIONAL, 'Name for greeting.', 'world');
    }
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $output->writeln(sprintf('Hello %s.', ucfirst($input->getArgument('name'))));
    }
}

続いて、上記コマンドを登録&実行するアプリケーションを作成します。
ファイル名はmain.phpとします。

<?php
require __DIR__ . '/vendor/autoload.php';
use Symfony\Component\Console\Application;
$application = new Application();
$application->add(new \Qcmnagai\Hello\HelloCommand());
$application->run();

この状態で実行してみると、以下のようになります。

$ php ./main.php hello
Hello World.

$ php ./main.php nagai
Hello Nagai.

Pharファイルを作ってみる

Pharファイルの作成ですが、Boxというツールを利用して作成するのが簡単です。

Boxのインストール

公式サイトに書かれているまま、以下のコマンドを実行します。

$ curl -LSs https://box-project.github.io/box2/installer.php | php

Box Installer
=============

Environment Check
-----------------

"-" indicates success.
"*" indicates error.

 - You have a supported version of PHP (>= 5.3.3).
 - You have the "phar" extension installed.
 - You have a supported version of the "phar" extension.
 - You have the "openssl" extension installed.
 * Notice: The "phar.readonly" setting needs to be off to create Phars.
 - The "detect_unicode" setting is off.
 - The "allow_url_fopen" setting is on.

Everything seems good!

Download
--------

 - Downloading manifest...
 - Reading manifest...
 - Downloading Box v2.7.5...
 - Checking file checksum...
 - Checking if valid Phar...
 - Making Box executable...

Box installed!

ここで注目してほしいのが、以下の文章です。

  • Notice: The “phar.readonly” setting needs to be off to create Phars.

phar.readonlyというのがオンになっているから、オフにしてねというメッセージなのですが、今回はphp.iniを触らずに実行時に解決したいと思いますので無視したいと思います。

box.jsonの作成

Pharファイルをどういう風に作成するかはbox.jsonというファイルに設定を書く必要があります。
今回は以下のようなファイルを用意しました。

{
    "alias": "hello.phar",
    "chmod": "0755",
    "compactors": [],
    "directories": ["src", "vendor"],
    "main": "main.php",
    "output": "hello.phar",
    "stub": true
}

大体内容を見ればわかると思います。
ちなみにstubという項目は、ローダスタブを設定するかどうかという項目らしく、これをtrueにしておくと、Phar拡張が有効になっていない環境でもPharファイルを直接読み込んだり、実行したりしてくれるらしいです。  

参考
PHP: Phar ファイルのスタブ - Manual

Pharファイルのビルド

$ ./box.phar build

で、通常は実行可能なのですが、phar.readonlyがオンになっている場合は、エラーになってしまうので、以下のように実行時に解決するようにします。

$ php -d phar.readonly=0 ./box.phar build

これで無事、hello.pharファイルが生成されたと思います。
実行してみましょう。

$ ./hello.phar hello
Hello World.

無事実行することが出来ました。

シングルコマンドアプリケーションにする

今回のhelloコマンドの場合、サブコマンドは必要ありません。
なので、本来なら以下のように実行したいところです。

$ ./hello.phar
Hello World.

この場合は、アプリケーションを作成するところを以下のように変更すればOKです。

 <?php
 require __DIR__ . '/vendor/autoload.php';
 use Symfony\Component\Console\Application;
 $application = new Application();
-$application->add(new \Qcmnagai\Hello\HelloCommand());
+$command = new \Qcmnagai\Hello\HelloCommand();
+$application->add($command);
+$application->setDefaultCommand($command->getName(), true);
 $application->run();

肝はsetDefaultCommand()ですね。
第2引数をtrueにすることで、シングルコマンドアプリケーションかどうかを設定しています。

また、以下のようにアプリケーション名とバージョンを設定することもできます。

<?php
-$application = new Application();
+$application = new Application('hello', '1.0.0');

この状態で、buildするとサブコマンドを指定しなくても、helloコマンドが実行されるようになりました!

$ ./hello.phar
Hello World.

$ ./hello.phar quartet
Hello Quartet.

イメージ通りのCLIツールの出来上がりです!

参考
Building a single Command Application (The Console Component - Symfony Docs)

おわりに

Pharファイルを初めて作ってみましたが、Boxのおかげでとても簡単に作成することが出来ました。
使い慣れたsymfony/consoleコンポーネントを利用して作成することもでき、個人的に満足度が高かったです。
これからCLIツールを作成する時は、PHPで作ることも選択肢に入れていきたいなと思いました!


カルテット開発部では、CLIツールもPHPで作成してしまうエンジニアを募集しています!