CLI scripts in Nette application

2 months ago by Miloslav Hůla translated by Depka     edit

When we talk about Nette application most of us will probably imagine a standard web application. Presenters, templates, forms and in the end some HTML output for the browser. But in parallel to the world of web browsers, there is another, completely different, command-line world (CLI). And if, for example, our web application needs to run some tasks periodically in the background, we need to delve into the CLI world.

In this article I'll describe how you can handle CLI scripts in Nette application with ease and without the need of any other libraries

Where to store the CLI scripts

When we decide to write some CLI script, the first thing that we need to solve is where to put it. This folder structure proved very beneficial to me over time:

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

The separation of scripts into bin and cron folders wasn't something that I came up with instantly. If you have more than a few scripts you'll quickly start to lose track of which you can just run manually and which runs periodically by itself. The story of how I accidentaly sent six thousand e-mails which were scheduled to a later time speaks for itself I think.

I've tried to use naming conventions like bin/cron-xyz.php or storing the scripts in bin/cron/ but for some reason I ended up with two separate folders.

Executable scripts

In Windows you can associate a program to open certain extensions by right clicking on the desired file, select “Open with” from the dropdown menu and choose php.exe. Afterwards we can run scripts just by double clicking on them like any regular programs. I develop on Windows, but I use Cygwin/GIT bash. If this is your case, then the procedure is the same as on Linux.

Running PHP script from CLI is easy:

> php bin/script.php

Writing the interpreter every time will get boring, however in PHP script you can take advantage of shebang:

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

Then you set the execute bit:

> chmod ug+x bin/script.php

And you can run it just like this:

> bin/script.php

# it works also with specifying the interpreter:
> php bin/script.php

Execute bit is very important for shebang. It's not a problem for people working on Linux but since I work on Windows I'm almost constantly forgetting to commit this bit, because on Windows, you must instruct git to do that:

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

I haven't found some elegant automated solution for this, but something like .gitattributes would be nice.

Using shebang on Windows is fine. PHP interpreter is ready for it and skips the shebang line. The problem here is with including files. If you need to call something like require 'bin/script.php'; the shebang line will be written to stdout.

With or without extension

I thought about dropping the .php extension because I thought that it was unnecessary Well, yes and no.

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

There are several disadvantages. Editors would not highlight, analyzers would not analyze and even find ''. -name '*.php' will stop working.

Integration with Nette

We will now create a script for cron task, which whill be doing DB maintenance. Every day after midnight it will delete logs that are older than one year, but there are some questions which we need to answer sooner or later.

Debug vs. Production

Which mode to use on website is clear. Tracy panel and red “bluecreens” are visible in debug mode. In production mode the application is silent and errors are quietly logged. So how it should be in CLI ?

We have to ask ourselves: “Who will run the CLI script?”

In my applications, it's always me or a fellow developer, or cron. Therefore, for CLI scripts I always turn on debug mode. If the script fails, a stack trace will pop up on me and I will read where the error is. If the cron script fails, the stack trace will be emailed to me (cron sends any stdout and stderr to the account under which the task is running). If you have it like me, let me just point out that you won't find anything in log/ folder when in debug mode.

Your situation could of course vary. Scripts may be executed by others and emails with cron outputs could not reach you at all. In this case I would suggest using production mode with optional CLI parameter --debug that enables debug mode.

Service from DI container

We can obtain services for CLI scripts from DI container, but the configuration for web or CLI could vary. Most common way to create DI container is by using Bootstrap class, which is loaded by Composer. Nette Sandbox does it in the same way and me too.

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
	{
		Tracy\Debugger::$maxDepth = 10;

		$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;
	}
}

This is the basis. Web app, specifically index.php, then does just this:

<?php

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

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

For the needs of CLI scripts we will create our own Bootstrap method. E.g. bootForCli() or bootForCron().

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

And we're all set. CLI scripts have everything they need.

Example of CLI script

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

namespace App\Cron;

use App;
use Dibi;

# Autoload of classes with Composer - and also of our Bootstrap class
require __DIR__ . '/../vendor/autoload.php';

# Configurator will compose DI container for us
$container = App\Bootstrap::bootForCron()
	->createContainer();

# We can fetch what we need from it
$db = $container->getByType(Dibi\Connection::class);

# Code that will execute
$db->query('DELETE FROM log.access WHERE at < now() - %s::interval', '1 year');

Afterwards we configure the cron task itself and we're done

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

Finally

The result is ridiculously short and if we encapsulate the deletion into service it will be even shorter.

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

I have shown to you how to write scripts for CLI very quickly and efficiently. Please don't take this as the ultimate one and only solution. The main disadvantage for me is processing of command-line parameters. Using getopt() is tedious. Parsing the parameters from $_SERVER['argv'] is unnecessarily difficult. So, I'm often using Nette Command-Line which is a great help.

There are also people that are using web for cron tasks and call it like this wget https://example.tld/cron/task

Another and maybe the most known solution for CLI is Symfony Console. It's certainly a very robust solution and since many developers use it, you'll find integrations in lots of packages/libraries e.g. DB migrations. You can also find it's integration into Nette on Componette website.

I personally will stay with just described one-purpose scripts. On the the one hand, it is a provided tooling and convenience of a third party library, on the other hand, it is a next dependency in Composer you have to constantly watch over.