osteel's blog Web development resources

Building a PHP CLI tool using DDD and Event Sourcing: distribution

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 a step-by-step guide to distributing a Laravel Zero application.

A painting of a delivery van

Distribution is about meeting your users where they are. The more options you offer, the broader public you can reach.

When it comes to PHP CLI tools, various distribution channels are available:

Dime's distribution strategy relies on PHP archives, or phars. Dime's users can either download the phar and use it as-is, instal it via Composer, or run it through a Docker container.

This post explains how I set up Dime to leverage and automate these distribution channels, laying out steps that can be applied to any Laravel Zero application.

Dime? If you are new to this series, Dime is a free and open-source command-line tool written in PHP to help people calculate their crypto-related taxes in the UK.

In this series

In this post

Application configuration

While most Laravel Zero applications can generate phars out-of-the-box, a few things can be done to optimise the result. The development and production (the app's distribution mode) environments are also likely to require separate configurations.

Hiding commands

This is what most Laravel Zero application menus will look like by default:

Dime  v0.1.2

USAGE: dime <command> [options] [arguments]

help             Display help for a command
migrate          Run the database migrations
process          Process a spreadsheet of transactions
review           Display a tax year's summary
test             Run the application tests
tinker           Interact with your application

app:build        Build a single file executable
app:install      Install optional components
app:rename       Set the application name

db:seed          Seed the database with records
db:wipe          Drop all tables, views, and types

make:command     Create a new command
make:factory     Create a new model factory
make:migration   Create a new migration file
make:model       Create a new Eloquent model class
make:seeder      Create a new seeder class
make:test        Create a new test class

migrate:fresh    Drop all tables and re-run all migrations
migrate:install  Create the migration repository
migrate:refresh  Reset and re-run all migrations
migrate:reset    Rollback all database migrations
migrate:rollback Rollback the last database migration
migrate:status   Show the status of each migration

A lot of these commands are mostly used for development and should not be available to end users. Thankfully, Laravel Zero provides a simple way to conditionally add and remove commands, through the app/commands.php configuration file.

Here is an extract from Dime's:

<?php

// ...

return [

    // ...

    'hidden' => array_merge(
        [
            NunoMaduro\LaravelConsoleSummary\SummaryCommand::class,
            Symfony\Component\Console\Command\DumpCompletionCommand::class,
            Illuminate\Console\Scheduling\ScheduleRunCommand::class,
            Illuminate\Console\Scheduling\ScheduleListCommand::class,
            Illuminate\Console\Scheduling\ScheduleFinishCommand::class,
            Illuminate\Foundation\Console\VendorPublishCommand::class,
            LaravelZero\Framework\Commands\StubPublishCommand::class,
        ],
        Phar::running() ? [
            Illuminate\Database\Console\Migrations\FreshCommand::class,
            Illuminate\Database\Console\Migrations\InstallCommand::class,
            Illuminate\Database\Console\Migrations\MigrateCommand::class,
            Illuminate\Database\Console\WipeCommand::class,
        ] : [],
    ),

    // ...

    'remove' => array_merge(
        [],
        Phar::running() ? [
            Illuminate\Database\Console\Factories\FactoryMakeCommand::class,
            Illuminate\Database\Console\Seeds\SeedCommand::class,
            Illuminate\Database\Console\Seeds\SeederMakeCommand::class,
            Illuminate\Database\Console\Migrations\MigrateMakeCommand::class,
            Illuminate\Database\Console\Migrations\RefreshCommand::class,
            Illuminate\Database\Console\Migrations\ResetCommand::class,
            Illuminate\Database\Console\Migrations\RollbackCommand::class,
            Illuminate\Database\Console\Migrations\StatusCommand::class,
            Illuminate\Database\Console\WipeCommand::class,
            Illuminate\Foundation\Console\ModelMakeCommand::class,
            LaravelZero\Framework\Commands\BuildCommand::class,
            LaravelZero\Framework\Commands\InstallCommand::class,
            LaravelZero\Framework\Commands\MakeCommand::class,
            LaravelZero\Framework\Commands\RenameCommand::class,
            LaravelZero\Framework\Commands\StubPublishCommand::class,
            LaravelZero\Framework\Commands\TestMakeCommand::class,
        ] : [],
    ),
];

There are two arrays – hidden and remove. Commands in the hidden array won't show in the application's menu, but they can still be run by the system. On the other hand, commands in the remove array won't be registered at all.

The reason we need both is because some commands that should not be available to end users are still needed by the application itself. For instance, Dime will reset the database every time the process command is called, which under the hood runs the migrate:fresh, db:wipe and migrate commands.

To make sure these commands are still available for development even though they won't show up in production, we check the application's context, here provided by the Phar class. The Phar::running method returns the path to the phar currently being executed, which is an empty string when the command was not run through a phar.

In other words, when the method returns an empty string, we are not in production.

Once the commands are properly configured, this is what Dime's menu looks like when displayed through the phar:

Dime  v0.1.2

USAGE:  <command> [options] [arguments]

help        Display help for a command
process     Process a spreadsheet of transactions
review      Display a tax year's summary

Production database

While the development database can live in the database folder without any issue, a PHP archive cannot update the files it contains, so the production database needs to be located elsewhere, outside the phar.

This is Dime's production database configuration from config/database.php:

<?php

// ...

'production' => [
    'driver' => 'sqlite',
    'url' => '',
    'database' => $_SERVER['HOME'] . '/.dime/database.sqlite',
    'prefix' => '',
    'foreign_key_constraints' => true,
],

$_SERVER['HOME'] resolves to the user's home directory (the one you can access via the ~ shortcut, e.g. /Users/alice), meaning the SQLite database is located in the ~/.dime folder (e.g. /Users/alice/.dime/database.sqlite).

The rest of the database configuration is the same as the local database.

Let's now see how to ensure the right database is picked up based on the current environment.

DotEnv add-on

Before distributing Dime, the only environment I had to deal with was local, so there was no need to worry about other configurations. With the introduction of a production environment, however, I needed a way to manage separate setups.

Most framework users will be familiar with DotEnv, a library to parse and load environment variables in PHP applications.

In Laravel Zero, it comes as an add-on:

$ php dime app:install dotenv

This command will load the library and create a .env file with some example entries.

I set Dime in a way that I only need a .env file for the local environment, with values that override the default ones, which are optimised for production.

In other words, I don't need to provide a production-specific .env file, which simplifies the build process.

I also set up Dime to match the database and environment names, meaning I only need a single entry in the local .env file:

APP_ENV=local

This environment variable needs to be set to production by default in two configuration files – first, in config/app.php:

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

And also in config/database.php:

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

And that's all there is to the production database's configuration. We're not done yet though – we now need to make sure the database.sqlite file always exists in ~/.dime, or the application won't run.

Database manager

The database migration commands require the database to exist before we can run them, meaning we somehow need to make sure the ~/.dime/database.sqlite file is always present.

In Dime, this is handled by the DatabaseManager service, whose logic roughly goes like this:

<?php

// ...

class DatabaseManager
{
    // ...

    public function prepare(): void
    {
        // Figure out the database connection's configuration path
        $entry = sprintf('database.connections.%s.database', $this->config->get('app.env'));

        // Retrieve the database configuration
        $file = $this->config->get($entry);

        // Create the database file if it doesn't exist
        if (! is_file($file)) {
            $directory = dirname($file);

            // Create the `~/.dime` directory first if it doesn't exist
            if (! is_dir($directory)) {
                $this->process->run(sprintf('mkdir -m755 %s', $directory));
            }

            // Create the file
            $this->process->run(sprintf('touch %s', $file));
        }

        // Migrate the database quietly
        $this->artisan->call('migrate:fresh', ['--force' => true], new NullOutput());
    }
}

$this->config, $this->process and $this->artisan are injected instances of Illuminate\Contracts\Config\Repository, Illuminate\Process\PendingProcess and Illuminate\Contracts\Console\Kernel, respectively.

The PendingProcess class comes from Laravel's Process component, which is based on Symfony's and was introduced in version 10.

NullOutput comes from Symfony's Console Component and ensures no output will be shown in the console.

As for the rest, the comments should be self-explanatory.

The DatabaseManager service guarantees that the database will always be there, and since this was the last production requirement, Dime is now distribution-ready.

Let's now explore the various distribution channels it uses and how to set them up.

Distribution through a PHP archive

Laravel Zero makes it super easy to turn your application into a phar. In fact, in most cases you'll be able to do it out-of-the-box, with this command that will generate an archive in the builds folder at the root:

$ php <your-app-name> app:build <your-build-name>

In some cases, however, you will need to make a few changes to make the phar more user-friendly and simplify its distribution.

Include the relevant folders

Laravel Zero uses the Box library under the hood to build phars. This package comes with a box.json configuration file that will work with most applications but that requires a few changes in some cases.

For instance, Dime needs the database and domain/src folders to be included in the build:

{
    "chmod": "0755",
    "directories": [
        "app",
        "bootstrap",
        "config",
        "database",
        "domain/src",
        "vendor"
    ],
    "..."
}

That's because using a database is optional in Laravel Zero and comes as an add-on, meaning the database folder is not included by default. And because Dime was developed following a DDD approach, it has an extra domain folder that needs including too.

Self-update

Another optional feature is the possibility for the phar to update itself if a new version is available.

This also comes as an add-on:

$ php dime app:install self-update

Once installed, a new self-update command will appear in the application's menu.

Different strategies can be used to download the latest archive from various sources, the default one being to look inside the builds folder on GitHub.

I went for a custom strategy for Dime, which downloads the phar from the latest release's assets (I'll get back to this).

To use it in place of the default one, you first need to publish the configuration file:

$ php dime vendor:publish --provider "LaravelZero\Framework\Components\Updater\Provider"

And then update the strategy in the config/updater.php file:

'strategy' => Strategy::class,

Release workflow

Dime's releases are tagged following the Semantic Versioning scheme. Each release comes with an updated phar, to be downloaded from the release's assets.

If we were to create a release manually, this is what we would do:

  1. Build and commit the phar;
  2. Create a new tag;
  3. Draft a new release;
  4. Upload the phar as a release asset; and
  5. Publish the release.

All these steps can be automated.

Dime uses a GitHub workflow for this, simplified below:

 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
name: Release

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Release version'
        type: string
        required: true

env:
  APP_ENV: "local"

jobs:

  phar:
    name: Phar
    runs-on: ubuntu-latest

    steps:
      - name: Validate tag
        uses: FidelusAleksander/gh-action-regex@v0.2.0
        with:
          regex_pattern: "^v\\d+(\\.\\d+)?(\\.\\d+)?$"
          regex_match_type: match
          text: ${{ github.event.inputs.version }}

      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.2"
          extensions: "bcmath"
          coverage: "none"

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

      - name: Build archive
        run: "php dime app:build dime --build-version=${{ github.event.inputs.version }}"

      - name: Push changes
        uses: stefanzweifel/git-auto-commit-action@v4
        with:
          branch: main
          commit_message: "Updated phar"

      - name: Draft release
        uses: softprops/action-gh-release@v1
        with:
          name: ${{ github.event.inputs.version }}
          tag_name: ${{ github.event.inputs.version }}
          draft: true
          files: ./builds/dime
          generate_release_notes: true

The workflow is triggered manually from the repository's Actions tab and takes the release's version as input:

Manual workflow trigger

The workflow consists of a single job, with the following steps:

  1. Validate the tag using a regular expression;
  2. Check out the code;
  3. Set up PHP with the right version and extensions;
  4. Instal the Composer dependencies;
  5. Build the archive using the submitted value as the version; and
  6. Draft the release.

That last step also uses the version as the release name and as the release's tag, and generates the release notes and uploads the phar as a release asset.

Also note the presence of the APP_ENV environment variable at the top, set to local. Laravel Zero won't build the archive if the application is in production mode, which is the case by default because of a change we've made in a previous section. So we override it here.

Once the workflow completes, the new archive can be downloaded from the latest release and used straight away:

$ php dime

The recommended approach, however, is to make the file executable and move it to a directory that's in the user's system path:

$ chmod +x dime
$ mv dime /usr/local/bin/dime

After doing so, the application can be invoked from anywhere running dime.

Distribution through Composer

If you've read some of my previous articles, you may be familiar with the distribution of command-line tools and packages through Composer and Packagist.

The gist of it is that, since we don't know much about the systems our work will end up on, it's worth making sure it is compatible with several dependency versions, to accommodate as many clients as possible.

The good news is that phars partially eliminate that need because they include (and isolate) the package's dependencies. In practice, this means we don't need to worry about another PHP application using a dependency version conflicting with ours.

That being said, phars still rely on the system's PHP version and installed extensions, so there is a limit to what can be isolated.

The other good news is that Laravel Zero supports distributing phars through Packagist and Composer almost natively, with just a few tweaks.

Composer configuration

Laravel Zero's default Composer configuration doesn't work with phars out-of-the-box.

First, the path to the binary needs to point to the archive in composer.json:

{
    "...",
    "bin": ["builds/dime"],
    "..."
}

Then, all dependencies but the supported PHP versions and required extensions must be moved to require-dev:

{
    "...",
    "require": {
        "php": "^8.2",
        "ext-bcmath": "*"
    },
    "require-dev": {
        "brick/date-time": "^0.4.2",
        "eventsauce/eventsauce": "^3.4",
        "eventsauce/message-repository-for-illuminate": "^0.4.2",
        "eventsauce/object-hydrator": "^1.3",
        "eventsauce/pest-utilities": "^3.4",
        "fakerphp/faker": "^1.21.0",
        "illuminate/database": "^10.13",
        "intonate/tinker-zero": "^1.2",
        "laravel-zero/framework": "^10.0",
        "..."
    },
    "..."
}

The reason for this is that, since our application's dependencies are already included in the phar, we don't need Composer to instal them again. Moving the dependencies to require-dev is a way to ensure it won't.

But how do we make sure that Box will include them in the archive?

Box configuration

As mentioned earlier, Laravel Zero relies on Box to generate phars from applications. We've already made a couple of changes in box.json, but we now need to make sure development dependencies are also included in the build.

We do this through the exclude-dev-files attribute:

{
    "...",
    "files": [
        "composer.json"
    ],
    "exclude-composer-files": false,
    "exclude-dev-files": false,
    "..."
}

By moving all dependencies to require-dev and setting exclude-dev-files to false, we ensure that the phar will include all the necessary dependencies while preventing Composer from installing them as well.

A downside to this approach is that actual development dependencies will also be included in the archive, however.

Exclude redundant files

When installing a PHP application through Packagist, Composer doesn't only instal the non-development dependencies – it also downloads the application's files.

Just like the dependencies, however, these files are already included in the phar, so Composer doesn't need to download them as well.

To make sure it doesn't, create a .gitattributes file at the root of the application to list the content you wish to exclude.

Here is Dime's, for instance:

* text=auto eol=lf

/.docker            export-ignore
/.github            export-ignore
/app                export-ignore
/bootstrap          export-ignore
/config             export-ignore
/database           export-ignore
/domain             export-ignore
/tests              export-ignore
/.editorconfig      export-ignore
/.env.example       export-ignore
/.gitattributes     export-ignore
/.gitignore         export-ignore
/box.json           export-ignore
/composer.lock      export-ignore
/dime               export-ignore
/phpstan.neon.dist  export-ignore
/phpunit.xml.dist   export-ignore
/pint.json          export-ignore
/rector.php         export-ignore

Hide the self-update command

The build process is currently set up in a way that Dime's binary always exposes a self-update command, although it should only be available when using the archive directly and not through Packagist or Docker.

The self-update command essentially replaces the archive with a newer version of itself, but when using another distribution channel, we want that channel to be responsible for the update, and not the archive itself.

How do we ensure that this command is only available in the relevant context?

The solution lies within the config/commands.php file again. Dime uses a helper class returning the archive's current context, which allows for deciding whether to include the command or not:

<?php

// ...

return [

    // ...

    'remove' => array_merge(
        [],
        Phar::running() ? [
            Illuminate\Database\Console\Factories\FactoryMakeCommand::class,
            Illuminate\Database\Console\Seeds\SeedCommand::class,
            // ...
        ] : [],
        Helpers::installedViaPhar() === false ? [
            LaravelZero\Framework\Components\Updater\SelfUpdateCommand::class,
        ] : [],
    ),
];

The Helpers::installedViaPhar method essentially checks that the archive is not currently being used via Composer:

<?php

// ...

public static function installedViaPhar(): bool
{
    return str_contains(__DIR__, '.composer/vendor') === false;
}

Release and distribution

At this stage, Dime is Composer-ready and, since the release workflow is already taking care of building the archive, all that's left to do is register the application on Packagist.

Log into packagist.org (create an account if necessary) and head over to the submit section of the website. Paste in the URL of your application's GitHub repository and give Packagist a couple of seconds to process it.

You should now be able to instal the latest release:

$ composer global require osteel/dime

If the ~/.composer/vendor/bin directory is already in your system's path, you can now invoke the binary from anywhere:

$ dime

Distribution through Docker

Phars can also be distributed through Docker images. With this method, Docker is the only dependency – no local versions of PHP or extensions are required.

Building the image

While the Laravel Zero documentation doesn't mention Docker images, the framework allows for building some through its reliance on Box:

$ ./vendor/laravel-zero/framework/bin/box docker builds/dime

This command reads the application's requirements from the archive's composer.json file and generates the corresponding Dockerfile, including the right PHP version and extensions:

1
2
3
4
5
6
7
8
FROM php:8.2-cli-alpine

COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions zlib bcmath

COPY builds/dime /docker/dime

ENTRYPOINT ["/docker/dime"]

While this is optional, I keep Dime's Dockerfile in a .docker folder at the root of the application.

And this is the command to build the image:

$ docker build -t ghcr.io/osteel/dime:latest -f .docker/Dockerfile .

The -t flag is to tag the image (ghcr.io/osteel/dime:latest) and the -f flag points to the Dockerfile's location.

I don't bother keeping track of previous image versions for Dime, so any new version gets the latest tag and overwrites the previous one.

Container registry

Once the image is built, we need to distribute it. A common way to do this is to push it to a registry – Docker Hub is a popular one, but I decided to stick to GitHub and use its Container Registry.

The first thing to do is to log into the platform using an access token. There are two types of tokens – fine-grained personal access tokens and personal access tokens (PAT). The former are deemed more modern and secure, but at the time of writing they were still in beta and I couldn't get them to work with Dime's release workflow, so I went for a regular PAT instead.

The latter can be obtained from the developer settings and must be created with the following permissions: write:packages, read:packages and delete:packages.

It's also possible to set an optional expiration date.

The generated token is then used as the password when logging into the container registry (the command will prompt for it):

$ docker login ghcr.io -u <USERNAME>

And this is how I push the image to the registry:

$ docker push ghcr.io/osteel/dime:latest

Once pushed, your image will show up as a package in the Packages tab of your GitHub profile:

Packages tab on GitHub

You can link the package to the repository holding its source code from the package's settings, as well as change its visibility:

Package's source repository

After which it will appear on the repository's page, under the Releases section on the right-hand side:

Repository's packages section

Release workflow

Like phars, any new release should come with an updated Docker image to be pulled from the container registry. And just like phars, this process can be automated.

This takes the form of a new job in Dime's release workflow, but before I give you the details, let's see how to add the PAT as a GitHub secret so the workflow can use it.

You need to head over to the repository's settings on GitHub and to the Secrets and variables menu on the left-hand side. It's got an Actions submenu that will take you to this form:

Repository actions secrets

Create a new repository secret (here named GHCR_TOKEN) and set the PAT as its value.

The workflow will then be able to use it to log into the registry and push images.

This is what the corresponding job looks like in Dime's workflow:

 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
name: Release

# ...

env:
  APP_ENV: "local"
  REGISTRY: "ghcr.io"

jobs:

  phar:
    # job details...

  docker:
    name: Docker
    needs: phar
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Log into registry
        uses: docker/login-action@v2
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GHCR_TOKEN }}

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v2

      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          context: .
          file: .docker/Dockerfile
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ${{ env.REGISTRY }}/${{ github.repository }}:latest

First, note the presence of the REGISTRY environment variable at the top. A new docker job has also been added, after the one building and committing the phar. It has a needs key referring to the latter, ensuring that the docker job only starts once the new phar is available.

The job consists of the following steps:

  1. Check out the code;
  2. Log into the container registry using the PAT as password and the repository owner's name as username;
  3. Set up Buildx to enable multi-platform images; and
  4. Build and push the image.

This last step also ensures the image is compatible with both AMD and ARM platforms and tags it by combining the registry name and repository name.

From there, any successful workflow run will update the latest image using the new phar and push it to the container registry for everyone to download and use:

$ docker run -it --rm ghcr.io/osteel/dime:latest help

This would download the image, start a container based on it and pass on the help command to the phar within the container.

Once the command is executed, the container is destroyed (--rm flag).

Shell script

While end users can run docker commands to use Dime, these are a bit verbose:

$ docker run -it --rm ghcr.io/osteel/dime:latest help

Things get worse when the transaction spreadsheet is involved and needs to be transferred from the user's computer to the container. The ephemeral nature of the container is also a factor, as whatever data it contains is likely to be lost at some point, including the SQLite database.

The solution to the above is to use bind mounts, a way to import local files or directories in a container.

Using this method, this is what running the process command looks like:

$ docker run -it --rm -v ~/.dime/database.sqlite:/root/.dime/database.sqlite -v transactions.csv:/tmp/transactions.csv ghcr.io/osteel/dime:latest process /tmp/transactions.csv

Two bind mounts are being used here – one for the database and one for the transaction spreadsheet. While this works, this is terrible user experience.

The solution I came up with is to wrap these commands in a small shell script that essentially converts regular Dime commands to Docker ones which, in turn, are converted back to Dime commands inside the container.

Here is a simplified version of the script:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#!/bin/sh

if ! [ -f "~/.dime/database.sqlite" ]; then
    mkdir -p ~/.dime && touch ~/.dime/database.sqlite
fi

command="docker run -it --rm -v ~/.dime/database.sqlite:/root/.dime/database.sqlite"

if [[ "$1" == "process" && -n "$2" && -f "$2" ]]; then
    filename=$(basename "$2")
    path=$(cd $(dirname $2); pwd)/$filename
    command+=" -v $path:/tmp/$filename ghcr.io/osteel/dime:latest process /tmp/$filename"
else
    command+=" ghcr.io/osteel/dime:latest ${@:1}"
fi

eval "$command"

It first makes sure the database exists locally, and creates it otherwise. It then starts putting together the Docker command, starting with mounting the database file onto the container, since we need it for every command.

The rest of the script depends on the executed command – if it's process, it mounts the transaction spreadsheet inside the container's tmp folder, using the spreadsheet's absolute local path.

If it's another command, it passes it to the container along with any arguments.

Finally, once the docker command is complete, the script executes it with eval.

End users can download the shell script and use it instead of running complicated Docker commands:

$ sh dime.sh

For an even better user experience, the recommended approach is to make the file executable and move it to a directory that's in the local system path:

$ chmod +x dime.sh
$ mv dime.sh /usr/local/bin/dime

After which the application can be used from anywhere running dime.

Closing thoughts

Distributing a PHP application can mean different things based on context.

Most developers only need to target a specific PHP version and usually freeze the application's dependencies through the composer.lock file. The application is then deployed to some server and made available from there, over HTTP.

Package maintainers often aim at supporting various PHP and dependency versions and build their continuous integration workflows accordingly.

Building standalone command-line applications with PHP is less common and poses a different set of questions when it comes to distribution: who am I targeting? On which platforms? Should I assume PHP will be available?

PHP archives have been around for 20 years and, despite their portable nature and the fact that they pretty much solve the dependency hell out-of-the-box, they seem to be rarely used.

Phars are very flexible and allow for a variety of distribution channels – they can be downloaded and used as-is or installed through Composer and Docker as this article illustrates. But they can also be distributed through Homebrew, Scoop, and maybe soon as native programs.

I've learnt a lot writing this series, but if I were to pick a single takeaway, it would be that PHP is way more versatile than I ever realised. And I've been working with this language for more than 15 years.

This post concludes the Dime series – at least for now. I still have a fairly long list of improvements I'd like to make and some of them may constitute enough material to become blog posts.

You can subscribe to email alerts below so you don't miss future articles, be they about Dime or something else. You can also follow me on Twitter where I share my content 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 github cicd phar docker composer packagist ]

Comments