Building a PHP CLI tool using DDD and Event Sourcing: setting up Laravel Zero
Last updated: 2023-07-10 :: Published: 2023-05-04 :: [ history ]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.
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
- Why?
- The domain
- The model
- Software design
- Setting up Laravel Zero ⬅️ you are here
- Getting started with EventSauce
- Distribution
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.php
– local
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.
You can also subscribe to the RSS or Atom feed, or follow me on Twitter.