osteel's blog Web development resources

Building a PHP CLI tool using DDD and Event Sourcing: setting up Laravel Zero

Been here before?

You can also subscribe to the RSS or Atom feed, or follow me on Twitter.

Although this post is part of the Building a PHP CLI tool using DDD and Event Sourcing series, you can also read it as an independent guide to getting started with Laravel Zero.

A heap of cogwheels on a wooden surface

While Symfony's Console component caters for most of my PHP CLI tools needs, some projects require a more feature-rich environment. Dime is a fairly complex piece of software involving things like a database and a comprehensive test suite, which is why I decided to use Laravel Zero as a foundation.

Laravel Zero is a micro-framework for console applications. It's a stripped-down version of Laravel, optimised for the command line. Most of the features Laravel developers are used to are also present in Laravel Zero, including database migrations and similar testing capabilities.

This post is a step-by-step guide to setting up Laravel Zero. While it is fine-tuned for Dime and opinionated at times, its content can easily be reused and adapted for your own projects.

The guide assumes some familiarity with Git and access to a terminal with local instals of PHP and Composer.

In this series

In this post

Initial setup

The first step I took was to create a GitHub repository for Dime. While there are other platforms to choose from, some parts of this guide rely on the code being hosted on GitHub.

Open a terminal on your local machine and create a new Laravel Zero project using Composer:

$ composer create-project --prefer-dist laravel-zero/laravel-zero dime

(Note that most commands in this guide will refer to Dime specifically – make sure to adjust them as appropriate.)

Enter the newly created directory and rename the project:

$ cd dime
$ php application app:rename dime

Create an empty Git repository in the directory and link it to GitHub:

$ git init
$ git branch -M main
$ git remote add origin git@github.com:osteel/dime.git

While this step is not required, I always add composer.lock to the .gitignore file in my CLI projects:

$ touch .gitignore
$ echo composer.lock >> .gitignore

When you distribute command-line applications through Packagist (and PHP packages in general), you know almost nothing about the systems they will be installed on. Because of that, PHP packages are distributed without the composer.lock file, so that Composer can figure out the most appropriate dependency versions to instal instead.

The composer.lock file won't be distributed whether you push it to the Git repository or not, but by adding it to the .gitignore file from the outset, we're a little bit closer to "production" conditions.

Let's issue our first commit and push it to GitHub:

$ git add .
$ git commit -m 'Laravel Zero'
$ git push -u origin main

Now that our local project and branch are fully linked to the GitHub repository, I won't mention pushing the code again. Feel free to do so whenever you feel is a good time.

Composer configuration

While the app:rename command we ran earlier renamed the project, there are other attributes in composer.json that you may want to update as well.

Here's the relevant section from Dime's, if you need some inspiration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
    "name": "osteel/dime",
    "description": "Extract tax-related data from crypto transactions",
    "keywords": ["crypto", "taxes", "hmrc", "php", "console", "cli"],
    "homepage": "https://github.com/osteel/dime",
    "type": "project",
    "license": "MIT",
    "support": {
        "issues": "https://github.com/osteel/dime/issues",
        "source": "https://github.com/osteel/dime"
    },
    "authors": [
        {
            "name": "Yannick Chenot",
            "email": "yannick@yellowraincoat.co.uk",
            "homepage": "https://github.com/osteel",
            "role": "Maintainer"
        }
    ],
    ...
}

You might also want to have a look at the composer.json schema reference to learn more about these attributes and make changes as you see fit.

Also, as I'm following a DDD approach for this project, most of the source code is in the domain folder, whose src directory needs its own domain name, which must be declared in the autoload section of composer.json:

"autoload": {
    "psr-4": {
        "App\\": "app/",
        "Domain\\": "domain/src/",
        "Database\\Factories\\": "database/factories/",
        "Database\\Seeders\\": "database/seeders/"
    }
},

Framework configuration

These steps relate to the configuration of Laravel Zero itself.

Add-ons

As mentioned earlier, Dime requires a database and, in Laravel Zero, that comes as an add-on to instal separately:

$ php dime app:install database

(Note that the application binary file was renamed for dime when we ran app:rename earlier, which will be reflected in the commands from now on.)

The default database engine is SQLite, but Redis is also supported.

Most Laravel developers will already be familiar with Tinker, a command-line environment to interact with Laravel applications. While Tinker ships with Laravel by default, in Laravel Zero it exists as a community-maintained package to instal separately:

$ composer require --dev intonate/tinker-zero

The documentation then instructs us to add TinkerZeroServiceProvider to config/app.php, but since I don't want Tinker to be available on "production" environments, I register it through AppServiceProvider instead, allowing me to do so conditionally:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Intonate\TinkerZero\TinkerZeroServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /** Register any application services. */
    public function register(): void
    {
        if ($this->app->environment() !== 'production') {
            $this->app->register(TinkerZeroServiceProvider::class);
        }
    }
}

Tinker is now available with this command:

$ php dime tinker

And that's it for the section about add-ons. The database and Tinker were the only ones I needed at the time, but many more are available – have a look at the "Add-ons" section of the documentation.

Safety mechanisms

There are quite a few safety mechanisms you can activate to prevent common mistakes in your Laravel applications. Not all of them are relevant to Dime, but some related to Eloquent can definitely help.

Since I personally do not shy away from using Laravel's ORM (despite some compelling criticism towards the Active Record pattern in general), Dime currently uses the following safety rules: preventing lazy loading, preventing accessing missing attributes, and preventing silently discarding attributes.

You will find a more detailed explanation and some examples if you follow the links, but here's a brief description for each of them:

  • Preventing lazy loading: This rule will trigger an exception any time some relationship data is retrieved using one database query for the main model as well as one separate query for each of the related models (the infamous "N+1 problem");
  • Preventing accessing missing attributes: This rule will trigger an exception any time the program tries to access a model attribute that doesn't exist, instead of failing silently. This sometimes happens because of a typo in an attribute's name, for instance;
  • Preventing silently discarding attributes: Related to the previous rule, this one will trigger an exception any time the program tries to assign a value to an attribute that doesn't exist, instead of failing silently.

These rules are to be enabled in AppServiceProvider's boot method:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace App\Providers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
use Intonate\TinkerZero\TinkerZeroServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    // ...

    /** Bootstrap any application services. */
    public function boot(): void
    {
        Model::preventAccessingMissingAttributes();
        Model::preventLazyLoading($this->app->environment() !== 'production');
        Model::preventSilentlyDiscardingAttributes();
    }
}

I'm also disabling the lazy loading safety mechanism in production, in a similar way I did Tinker earlier.

Static analysis

Static analysis refers to inspecting the source code of a program without actually executing it. This is typically done by automated tools that can identify potential issues, such as syntax errors and security vulnerabilities.

I'm using two static analysis tools for Dime – Laravel Pint and PHPStan.

Laravel Pint

Laravel Pint is a Laravel package built on top of PHP CS Fixer. It aims at verifying your code's conformity to a specific style guide and will automatically fix violations where possible.

Installation is straightforward:

$ composer require --dev laravel/pint

Pint uses the Laravel preset by default, so you can run it straight away to apply the corresponding rules:

$ ./vendor/bin/pint

If you wish to configure it further though, you can do so by adding a pint.json file to the root of your project.

This is how you would use the PSR-12 preset instead of Laravel's, for instance:

{
    "preset": "psr12"
}

As Pint is essentially a wrapper around PHP CS Fixer, you can also use and configure any of its rules.

Here is one used by Dime, for instance:

{
    "preset": "psr12",
    "rules": {
        "align_multiline_comment": {
            "comment_type": "all_multiline"
        }
    }
}

This is just a small sample though – Dime's full configuration is quite long and can be found here.

There's also more to Pint, like some other useful commands and options – check out the documentation for details.

PHPStan

The other static analysis tool I'm using for Dime is PHPStan. Where Laravel Pint checks your code for style guide violations, PHPStan looks for potential typing issues and other bugs.

Installation is done via Composer as well:

$ composer require --dev phpstan/phpstan

And just like Pint, you can use PHPStan straight away, without configuration:

$ ./vendor/bin/phpstan analyse app

The default setup will perform very basic checks, however. PHPStan has ten different levels of strictness, ranging from 0 to 9. The default level is 0, which is the most permissive one.

While it is possible to specify the level to use as a command option, I prefer to add a phpstan.neon configuration file to the root of the project, similar to Pint.

Here is Dime's, followed by an explanation of the various parameters:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
parameters:
    level: 9

    paths:
        - app
        - domain/src

    checkMissingCallableSignature: true

    type_coverage:
        return_type: 100
        param_type: 100
        property_type: 100
        print_suggestions: true

The first parameter is the level, which is set to the highest value 9. PHPStan's strictness spectrum is convenient when working with existing code, as you can set a low level to begin with and work your way up progressively, without having to deal with too many issues at once. But since we're starting a project from scratch, we might as well go for the maximum strictness straight away.

You can also specify the folders to analyse in the paths section. Dime follows a Domain-Driven Design approach and has code in both the app and domain/src folders.

Next is the checkMissingCallableSignature property. By default, PHPStan won't flag any missing signature for callables and closures. Enabling checkMissingCallableSignature will ensure it does.

Finally, type_coverage comes from a PHPStan extension that determines what percentage of your code base is typed. You can set the desired percentage for each category (return types, argument types and property types) and the analysis will fail if the actual percentage is less than that. By enabling print_suggestions, the extension will also tell you where exactly the types are missing.

The extension is installed via Composer as well but requires setting up PHPStan's extension installer first:

$ composer require --dev phpstan/extension-installer

Then the extension:

$ composer require --dev tomasvotruba/type-coverage

You can now run this command and it will automatically pick up the configuration file:

$ ./vendor/bin/phpstan

Then again, this is the configuration I came up with for Dime. Feel free to adjust it to your needs, or even skip some parts altogether if they feel like overkill.

Composer scripts

While the commands to run Pint and PHPStan are fairly simple, especially when using configuration files, we can further improve the developer experience by leveraging Composer scripts.

Open composer.json and add the following section to the end, before the last closing bracket:

"scripts": {
    "pint": "pint",
    "stan": "phpstan"
    "all": [
        "@pint",
        "@stan"
    ]
}

You can now run Pint with the command composer pint, PHPStan with composer stan, and both of them with composer all.

Testing

Laravel Zero provides a testing environment similar to Laravel's out of the box. There's a tests folder with some Feature and Unit sub-folders and some example tests, and a phpunit.xml.dist file at the root.

The main difference is that Laravel Zero uses Pest by default.

Pest is an extra layer on top of PHPUnit, introducing a simple and elegant syntax while being fully compatible with existing PHPUnit test suites.

I won't spend too much time covering Pest here, because the next part of this series will focus on Test-Driven Development with Pest. I'll just explain how Pest is set up in Dime.

Environment configuration

The domain folder has its own tests directory, which needs to be added to phpunit.xml.dist:

...
<testsuites>
    <testsuite name="Application">
        <directory suffix="Test.php">./tests</directory>
    </testsuite>
    <testsuite name="Domain">
        <directory suffix="Test.php">./domain/tests</directory>
    </testsuite>
</testsuites>
...

As I'm interested in the code coverage metric as well, the domain's src directory also needs to be included in the list of source code files:

...
<source>
    <include>
        <directory suffix=".php">./app</directory>
        <directory suffix=".php">./domain/src</directory>
    </include>
</source>
...

Finally, the domain's tests folder also needs its own domain name, which must be declared in the autoload-dev section of composer.json:

"autoload-dev": {
    "psr-4": {
        "Tests\\": "tests/",
        "Domain\\Tests\\": "domain/tests/"
    }
},

Test database

Some tests directly run against the database, so I need a separate one to avoid tampering with real data.

Dime has two database connections in config/database.phplocal and testing. The former is the default connection:

<?php

// ...

return [

    'default' => env('APP_ENV', 'local'),

    // ...

    'connections' => [

        'local' => [
            'driver' => 'sqlite',
            'url' => '',
            'database' => database_path('database.sqlite'),
            'prefix' => '',
            'foreign_key_constraints' => true,
        ],

        'testing' => [
            'driver' => 'sqlite',
            'database' => ':memory:',
            'prefix' => '',
        ],

    ],

    // ...
]

Both are using the sqlite driver, but the test database is stored in memory for increased performance. Also note that I'm using the APP_ENV environment variable when available as the default database, instead of the usual DB_CONNECTION.

Its value needs to change while running the tests, so the test database is picked up instead of the production one.

This is done by adding the following section to the <phpunit> tag in phpunit.xml.dist:

...
<php>
    <server name="APP_ENV" value="testing"/>
</php>
...

If that's not already the case, also make sure that env's default value in config/app.php is local:

'env' => env('APP_ENV', 'local'),

The testing environment is now complete – you should be able to run the example tests:

$ ./vendor/bin/pest

For consistency and convenience, we can also add this command to the Composer scripts in composer.json, so we can run the tests with composer test:

"scripts": {
    "pint": "pint",
    "stan": "phpstan",
    "test": "pest --coverage"
    "all": [
        "@pint",
        "@stan",
        "@test"
    ]
}

Try out the new command – you will notice some extra information at the end of the execution. This is due to the --coverage flag that I added to the Composer script command.

With this option, Pest will return the percentage of code that is executed by the tests on top of running the test suites. That's the code coverage metric I mentioned earlier, and I will talk about it more in the next section.

Continuous integration

While Pint, PHPStan and the tests can all be run locally to help us write better code overall, we are not being forced to use these tools.

In theory, we could push some code that breaks the tests or violates the rules and get away with it, intentionally or not. What's to stop us?

This is where a continuous integration workflow can help.

Continuous integration (CI) refers to a practice whereby team members merge their work regularly, using some kind of build process (the workflow) to ensure no error slips through.

While Dime is a one-man show and the first part of this definition doesn't really apply to it, the second part is interesting because it involves automating the checks we've just put in place. This is what this section is about.

The goal of CI in this case is to prevent any code violating the rules from being merged into the main branch. GitHub conveniently offers many continuous integration features, which we are going to use.

Create the following ci.yml file in a new .github/workflows folder, at the root of your project:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:

  style:
    name: Style
    runs-on: ubuntu-latest

    steps:
      - name: Check out
        uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2

      - name: Install composer dependencies
        uses: ramsey/composer-install@v2

      - name: Check coding style
        run: vendor/bin/pint --test

  analysis:
    name: Analysis
    runs-on: ubuntu-latest

    steps:
      - name: Check out
        uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2

      - name: Install composer dependencies
        uses: ramsey/composer-install@v2

      - name: Analyse code
        run: vendor/bin/phpstan

  tests:
    name: Tests
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        php-version:
          - "8.2"
        dependency-versions:
          - "lowest"
          - "highest"

    steps:
      - name: Check out
        uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          coverage: none

      - name: Install composer dependencies
        uses: ramsey/composer-install@v2
        with:
          dependency-versions: ${{ matrix.dependency-versions }}

      - name: Run test suite
        run: vendor/bin/pest --coverage --min=100

At the very top is the name of the workflow (I came up with "CI" in a moment of mad inspiration), followed by the conditions on which it should trigger:

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

Here, the workflow will trigger any time the main branch receives a new commit, or any time a pull request targetting the main branch is opened.

Next are the jobs that make up the workflow – style, analysis, and tests.

Each job has a name (name), a platform it should run on (runs-on) and a series of steps to execute (steps). The jobs all run on the latest Ubuntu version, which is fairly standard when it comes to CI workflows, especially when there is no clearly defined production environment.

The style job runs Laravel Pint to check whether the code follows a specific style guide:

style:
  name: Style
  runs-on: ubuntu-latest

  steps:
    - name: Check out
      uses: actions/checkout@v3

    - name: Setup PHP
      uses: shivammathur/setup-php@v2

    - name: Install composer dependencies
      uses: ramsey/composer-install@v2

    - name: Check coding style
      run: vendor/bin/pint --test

The first three steps are the same for all three jobs – checking out the code, setting up PHP, and installing the Composer dependencies. They all use GitHub Actions, which are some kind of building blocks for your workflows.

The fourth step of the job is the actual Pint command, triggering the analysis. Note the presence of the --test flag, ensuring errors are reported but no file is updated. Aside from this flag, the command works the same as locally – Pint will read the pint.json configuration file as well.

The second job – analysis – runs PHPStan:

analysis:
  name: Analysis
  runs-on: ubuntu-latest

  steps:
    - name: Check out
      uses: actions/checkout@v3

    - name: Setup PHP
      uses: shivammathur/setup-php@v2

    - name: Install composer dependencies
      uses: ramsey/composer-install@v2

    - name: Analyse code
      run: vendor/bin/phpstan

It's very similar to style apart from its fourth step, which runs PHPStan instead of Pint. Just like Pint though, PHPStan will pick up the phpstan.neon configuration file, same as it would locally.

The third and last job – tests – deals with running the test suites:

tests:
  name: Tests
  runs-on: ubuntu-latest

  strategy:
    fail-fast: false
    matrix:
      php-version:
        - "8.2"
      dependency-versions:
        - "lowest"
        - "highest"

  steps:
    - name: Check out
      uses: actions/checkout@v3

    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: ${{ matrix.php-version }}
        coverage: none

    - name: Install composer dependencies
      uses: ramsey/composer-install@v2
      with:
        dependency-versions: ${{ matrix.dependency-versions }}

    - name: Run test suite
      run: vendor/bin/pest --coverage --min=100

It's got an extra section that the others don't – strategy.

We already mentioned that we don't know the exact configuration of the systems our application will be installed on. Composer will do its best to figure out the dependency versions that are compatible with what's already on the host, which is another way to say that it could potentially instal any of the dependency versions within the range of what composer.json says is valid.

But how do we know that our application is compatible with all these dependency versions?

I've published a whole article about compatibility checking that you may want to check out later, but the gist of it is that we can run the tests for various dependency versions by defining a strategy for our job.

Our goal here is to run the tests twice – once using the lowest available dependency versions and once using the highest ones. If the tests pass in both cases, we can be fairly confident our application is compatible with the whole range of dependency versions.

The strategy uses a matrix:

strategy:
  fail-fast: false
  matrix:
    php-version:
      - "8.2"
    dependency-versions:
      - "lowest"
      - "highest"

This means the job's steps will run for each combination of php-version and dependency-versions. The job will essentially run twice – once using PHP 8.2 with the lowest compatible dependency versions, and once using PHP 8.2 with the highest compatible dependency versions.

We could arguably do away with php-version here since it contains a single value. But adding it to the matrix now means we'll be able to support other versions down the line if necessary (e.g. PHP 8.3).

The fail-fast attribute means that the workflow won't stop at the first error and will execute the whole matrix instead, ensuring we get thorough feedback.

This is how the matrix's values are injected into the relevant steps:

steps:
  - name: Check out
    uses: actions/checkout@v3

  - name: Setup PHP
    uses: shivammathur/setup-php@v2
    with:
      php-version: ${{ matrix.php-version }}
      coverage: none

  - name: Install composer dependencies
    uses: ramsey/composer-install@v2
    with:
      dependency-versions: ${{ matrix.dependency-versions }}

  - name: Run test suite
    run: vendor/bin/pest --coverage --min=100

The last step is the Pest command itself, running the tests and measuring the percentage of coverage with the --coverage flag.

But there's also a second flag that we haven't seen yet – --min=100. This option means that Pest will return an error unless there is 100% test coverage. This may seem a bit ambitious, but then again it's easier to set the bar high at the beginning of the project.

Also, the broader our test suite is, the more relevant our job strategy will be. If only 10% of our code base is covered with tests, all the job demonstrates is that 10% of the code base is compatible with the dependency versions we claim it is.

Still, 100% may be unrealistic in some cases, so feel free to change the value for something more appropriate.

That's it for the section about continuous integration. While no setup is perfect, this combination of a fine-tuned Laravel Pint, of PHPStan set to its strictest level, and a test suite that expects 100% coverage is already a solid starting point.

Add to the mix a CI workflow that enshrines these rules and won't let any violation through and you've got yourself a robust setup that will help you maintain quality over time, whether you work on your own or with a team.

Closing thoughts

I've been exploring the topic of PHP on the command line quite a bit recently, writing about it and also talking about it at meetups.

And something I've been saying in my talk is that building for the console means backend developers can create and ship entire products too, since no frontend nor infrastructure skills are required. I don't know about monetising, but it's a great way to put together a portfolio.

The aforementioned tutorial and talk are about leveraging Symfony's Console component to develop small CLI programs, but the library will fall short when it comes to complex software. This is where frameworks like Laravel Zero shine, by bringing advanced features to shell environments and empowering developers to build and ship sophisticated products for the console.

The next instalment of this series will cover how to get started with EventSauce within a Laravel environment. EventSauce is an Event Sourcing library for PHP, and the post will be a step-by-step guide to building an aggregate from scratch, following a Test-Driven Development approach using Pest.

Subscribe to email alerts below so you don't miss it, or follow me on Twitter where I will share it as soon as it is available.

Enjoying the content?

You can also subscribe to the RSS or Atom feed, or follow me on Twitter.

Last updated by osteel on :: [ laravelzero laravelpint phpstan github workflow cli ]

Comments