CLI Scripts in a Nette Application

5 years ago by Miloslav Hůla  

When we say Nette application, a web application comes to mind. Presenters, templates, forms, and subsequently HTML output for the browser. But parallel to the world of web browsers, there exists another, completely different, world of the command line (CLI). And if our web application needs to periodically perform some background tasks, we must venture into the world of CLI.

In this article, I will describe one way to handle CLI scripts in a Nette application. I'll explain how to easily create single-purpose scripts without the need for additional libraries.

Where to Store CLI Scripts

When we decide to write a CLI script, the first thing we need to resolve is where to store it. I have found the following directory structure to be effective:

app/
bin/   <-- single-purpose CLI scripts
cron/  <-- scripts for cron
log/
sessions/
www/

I arrived at the separation of bin and cron folders over time. If you have multiple scripts, you may start losing track of which ones can be run manually and which ones are run periodically by themselves. I won’t go into detail about the time I accidentally sent six thousand emails at two in the afternoon that were supposed to be sent after midnight.

I tried using the convention bin/cron-xyz.php, and other times a subfolder bin/cron/, but for some reason, I ended up with two separate folders.

Executable Scripts

On Windows, you can right-click on a PHP file, choose “Open with…”, and select the path to php.exe. Then you can run the scripts by double-clicking them like ordinary programs. Personally, I develop on Windows but use Cygwin/GIT bash. The process is then the same as on Linux.

Running a PHP script from CLI is simple:

> php bin/script.php

Typing the interpreter repeatedly gets boring. In the PHP script, we can use a shebang. I use:

#!/usr/bin/env php
<?php

Set the executable bit:

> chmod ug+x bin/script.php

And we can run:

> bin/script.php

# still works with the interpreter:
> php bin/script.php

The execute bit is an important detail for the shebang. On Linux, it's fine, but since I develop on Windows, I constantly forget to version this flag. On Windows, we have to instruct git:

> git add bin/script.php --chmod=+x

I haven't found any elegant automated solution yet. Something like .gitattributes would be nice.

Using shebang on Windows doesn't cause any issues. The PHP interpreter is prepared for it and skips the shebang line. However, there's a problem with including files. If you consider calling require 'bin/script.php';, the shebang line will be printed to standard output, and that’s an issue.

With or Without Extension

At one time, I toyed with the idea of dropping the .php extension from scripts. It seemed insignificant.

> bin/script     # shorter
> bin/script.php # vs. longer

But I gave it up. Several disadvantages emerged. Editors didn’t highlight, analyzers didn’t analyze, and even a simple find . -name '*.php' stopped working.

Integration with Nette

We will work towards a cron task script that will perform database maintenance. It will delete logs older than one year every midnight. However, there are a few questions we will eventually face.

Debug vs. Production

Which mode to use on the web is clear and established. In debug mode, the Tracy panel and red “bluescreens” are displayed, while in production mode, the application is silent and only logs errors. But what about CLI?

We must ask the question: “Who will run the CLI script?”

In my applications, it’s always me, a colleague developer, or cron. Therefore, I always enable debug mode for CLI scripts. If the script fails, a stack trace is displayed, and I can read where the error is. If a cron script fails, the stack trace is emailed to me (cron sends any stdout and stderr to the email account under which the task runs). If this is the case for you as well, just a reminder that in debug mode, you won't find anything in the log/ directory.

Your situation might be different. Scripts might be run by others, and cron output emails might not reach you. In that case, I would prefer production mode with an optional debug mode enabled via a CLI parameter, such as --debug.

Services from the DI Container

We will get services for CLI scripts from the DI container, but its configuration for the web or CLI may differ. The current trend in creating a DI container (and Nette Sandbox does it this way too) is using the Bootstrap class, which is loaded by Composer. I do it basically the same way:

namespace App;

use Nette\Configurator;
use Tracy;

final class Bootstrap
{
    public static function boot(): Configurator
    {
        return self::createConfigurator([
            '10.10.0.5',  # milo.dev
        ]);
    }

    private static function createConfigurator($debugMode): Configurator
    {
        $configurator = new Configurator;
        $configurator->setDebugMode($debugMode);
        $configurator->enableDebugger(__DIR__ . '/../log', 'milo@example.tld');
        $configurator->setTempDirectory(__DIR__ . '/../temp');

        $configurator->createRobotLoader()
            ->addDirectory(__DIR__)
            ->addDirectory(__DIR__ . '/../lib')
            ->register();

        $configurator->addConfig(__DIR__ . '/config/common.neon');
        $configurator->addConfig(__DIR__ . '/config/local.neon');

        return $configurator;
    }
}

That's the basics. The web application, specifically index.php, just does:

<?php

require __DIR__ . '/../vendor/autoload.php';

App\Bootstrap::boot()->createContainer()
    ->getByType(Nette\Application\Application::class)
    ->run();

For CLI scripts, we will create our own Bootstrap method. It can be bootForCli() or bootForCron(), depending on personal preference:

public static function bootForCron(): Configurator
{
    # Debug mode only if the --debug switch is present
    return self::createConfigurator(in_array('--debug', $_SERVER['argv'], true));
}

And with that, we have everything prepared for CLI scripts.

Example CLI Script

#!/usr/bin/env php
<?php

namespace App\Cron;

use App;
use Dibi;

# Autoloading classes via Composer - including our Bootstrap class
require __DIR__ . '/../vendor/autoload.php';

# Let the configurator build the DI container for us
$container = App\Bootstrap::bootForCron()
    ->createContainer();

# We can now extract the services we need from the container
$db = $container->getByType(Dibi\Connection::class);

# The piece of code we want to execute
$db->query('DELETE FROM log.access WHERE at < now() - %s::interval', '1 year');

Then configure the cron task, and we’re done.

00 00   * * *   www-data   /var/www/sites/example.tld/cron/clean-up-logs.php

Conclusion

The resulting code is almost laughably short. And if we encapsulated the log deletion example into a standalone service, the code would be even shorter:

App\Bootstrap::bootForCron()
    ->createContainer()
    ->getByType(VictorTheCleaner::class)
    ->clean();

I showed how to write scripts for CLI very quickly and easily. Don’t take it as the “only correct” solution. The main drawback I encounter is handling command-line parameters. Using getopt() is tedious. Parsing parameters from $_SERVER['argv'] is laborious. Occasionally, I use the Nette Command-Line package, which significantly eases parameter handling.

Some people handle cron jobs via the web, periodically calling wget https://example.tld/cron/task.

Another, and perhaps the most well-known solution for CLI is Symfony Console. It’s certainly a robust solution, and since many developers use it, you’ll find integration in many packages, such as database migrations. On Componette, you can find integration for Nette as well.

As for me, if I can, I still stick to the described single-purpose scripts. After all, it’s just a matter of balance. On one side, you have the tooling and convenience provided by a third-party library, and on the other side, you have a dependency in Composer that you need to constantly monitor.