beram's blog.
Github GitLab Twitter
« Back to homepage.

Autoload PHPUnit phar

For some time now, instead of installing PHPUnit using composer or symfony/phpunit-bridge I'm trying to use its phar.

One of many reasons is because I'd like to decrease the number of downloaded dependency.

I really like how PHPStan handles it.
composer require --dev phpstan/phpstan will only download the phar so no extra dependencies. When developing an extension you still have all the classes available and discoverable with PHPStorm or else etc.. It is just beautiful and a great pleasure to use PHPStan or develop extensions for it! 😍

So I am testing PHIVE to manage tools that don't have the same approach as PHPStan. That's the case of PHPUnit.

An issue with this setup is that PHPStan cannot analyse the tests because it won't be able to discover PHPUnit's symbols. If you search in PHPStan's issue queue, you may find some like Standalone PHPUnit PHAR and TestCase class not found errors.

The way to resolve the issue at the time is no longer supported since autoload_files has been removed in version 1.0.0

If we try the "new" bootstrapFiles configuration (it has been out for quite sometime now, so it is not that new 😛) like:

parameters:
	bootstrapFiles:
	    - phar://%rootDir%/../../../tools/phpunit.phar

We have an error:

$ ./tools/phpstan
Note: Using configuration file /app/phpstan.neon.dist.
Bootstrap file phar:///app/vendor/phpstan/phpstan/../../../tools/phpunit.phar does not exist.

So what changed between autoload_files and bootstrapFiles?

Not that much :P

That's the autoload_files part (see the commit that removed it):

foreach ($autoloadFiles as $parameterAutoloadFile) {
	if (!file_exists($parameterAutoloadFile)) {
		$errorOutput->writeLineFormatted(sprintf('Autoload file %s does not exist.', $parameterAutoloadFile));
		throw new \PHPStan\Command\InceptionNotSuccessfulException();
	}
	(static function (string $file) use ($container): void {
		require_once $file;
	})($parameterAutoloadFile);
}

And that's the bootstrapFiles (see this file):

private static function executeBootstrapFile(
	string $file,
	Container $container,
	Output $errorOutput,
	bool $debugEnabled,
): void
{
	if (!is_file($file)) {
		$errorOutput->writeLineFormatted(sprintf('Bootstrap file %s does not exist.', $file));
		throw new InceptionNotSuccessfulException();
	}
	try {
		(static function (string $file) use ($container): void {
			require_once $file;
		})($file);
	} catch (Throwable $e) {
		$errorOutput->writeLineFormatted(sprintf('%s thrown in %s on line %d while loading bootstrap file %s: %s', get_class($e), $e->getFile(), $e->getLine(), $file, $e->getMessage()));

		if ($debugEnabled) {
			$errorOutput->writeLineFormatted($e->getTraceAsString());
		}

		throw new InceptionNotSuccessfulException();
	}
}

The first one uses file_exists whereas the latter uses is_file.

If you isolate this part we'll see the difference. Let's execute this script:

<?php

$path = 'phar://'.__DIR__.'/some.phar';

\var_dump(
    \file_exists($path),
    \is_file($path),
);

The result is:

$ php require-phar.php
bool(true)
bool(false)

At first, I didn't know if it was a bug in PHP or the expected behavior. I've even opened an issue to have more information on it 😅

After a little pause and some fresh air it became clearer when I thought about the fact that is_file works great with file inside a phar:

<?php

\is_file('phar://'.__DIR__.'/some.phar/file.php');

Ho! Wait! 'phar://'.__DIR__.'/some.phar' is considered a directory!

Ok so now that we know how PHPStan handle the bootstrapFiles, how could we configure it to let its autoloader access PHPUnit?

Since require_once is used we could just directly use the phar file:

parameters:
	bootstrapFiles:
	    - ./tools/phpunit.phar

It works nevertheless require_once also execute the code.

Do we really want to execute a code that has not been designed to be required this way?

Let's take a look!

When requiring a phar like that:

require_once __DIR__.'/phpunit.phar';

The phar file stub is the executed file.

The stub for PHPUnit phar looks like this (it is the PHP Autoload Builder template used by PHPUnit):

#!/usr/bin/env php
<?php
if (!version_compare(PHP_VERSION, PHP_VERSION, '=')) {
    fwrite(
        STDERR,
        sprintf(
            '%s declares an invalid value for PHP_VERSION.' . PHP_EOL .
            'This breaks fundamental functionality such as version_compare().' . PHP_EOL .
            'Please use a different PHP interpreter.' . PHP_EOL,

            PHP_BINARY
        )
    );

    die(1);
}

if (version_compare('7.3.0', PHP_VERSION, '>')) {
    fwrite(
        STDERR,
        sprintf(
            'PHPUnit X.Y.Z by Sebastian Bergmann and contributors.' . PHP_EOL . PHP_EOL .
            'This version of PHPUnit requires PHP >= 7.3.' . PHP_EOL .
            'You are using PHP %s (%s).' . PHP_EOL,
            PHP_VERSION,
            PHP_BINARY
        )
    );

    die(1);
}

foreach (['dom', 'json', 'libxml', 'mbstring', 'tokenizer', 'xml', 'xmlwriter'] as $extension) {
    if (extension_loaded($extension)) {
        continue;
    }

    fwrite(
        STDERR,
        sprintf(
            'PHPUnit requires the "%s" extension.' . PHP_EOL,
            $extension
        )
    );

    die(1);
}

if (__FILE__ === realpath($_SERVER['SCRIPT_NAME'])) {
    $execute = true;
} else {
    $execute = false;
}

$options = getopt('', array('prepend:', 'manifest'));

if (isset($options['prepend'])) {
    require $options['prepend'];
}

if (isset($options['manifest'])) {
    $printManifest = true;
}

unset($options);

define('__PHPUNIT_PHAR__', str_replace(DIRECTORY_SEPARATOR, '/', __FILE__));
define('__PHPUNIT_PHAR_ROOT__', 'phar://___PHAR___');

Phar::mapPhar('___PHAR___');

spl_autoload_register(
    function ($class) {
        static $classes = null;

        if ($classes === null) {
            $classes = [___CLASSLIST___];
        }

        if (isset($classes[$class])) {
            require_once 'phar://___PHAR___' . $classes[$class];
        }
    },
    ___EXCEPTION___,
    ___PREPEND___
);

foreach ([___CLASSLIST___] as $file) {
    require_once 'phar://___PHAR___' . $file;
}

require __PHPUNIT_PHAR_ROOT__ . '/phpunit/Framework/Assert/Functions.php';

if ($execute) {
    if (isset($printManifest)) {
        print file_get_contents(__PHPUNIT_PHAR_ROOT__ . '/manifest.txt');

        exit;
    }

    unset($execute);

    PHPUnit\TextUI\Command::main();
}

__HALT_COMPILER();

It starts with some check about PHPUnit requirements, contains the autoload part which interest us, the entry point to execute PHPUnit command, and some extra options.

You could see that it will only execute PHPUnit when the phar is directly executed from command line thanks to:

//..

if (__FILE__ === realpath($_SERVER['SCRIPT_NAME'])) {
    $execute = true;
} else {
    $execute = false;
}

// ...

if ($execute) {
    // ...
    PHPUnit\TextUI\Command::main();
}

//..

That's actually pretty nice! It already contains everything we need and want out of the box!

I also discover that another PHPUnit phar is available! This one is especially built to use PHPUnit as a library only! It is absolutely awesome! Take a look at the template of the phar stub file:

<?php declare(strict_types=1);
define('__PHPUNIT_PHAR__', str_replace(DIRECTORY_SEPARATOR, '/', __FILE__));
define('__PHPUNIT_PHAR_ROOT__', 'phar://___PHAR___');

Phar::mapPhar('___PHAR___');

spl_autoload_register(
    function ($class) {
        static $classes = null;

        if ($classes === null) {
            $classes = [___CLASSLIST___];
        }

        if (isset($classes[$class])) {
            require_once 'phar://___PHAR___' . $classes[$class];
        }
    },
    ___EXCEPTION___,
    ___PREPEND___
);

foreach ([___CLASSLIST___] as $file) {
    require_once 'phar://___PHAR___' . $file;
}

require __PHPUNIT_PHAR_ROOT__ . '/phpunit/Framework/Assert/Functions.php';

__HALT_COMPILER();

It contains only the autoload part!

We could use this one if we want to make sure nothing else pollutes PHPStan analysis.

PHPUnit phar is scoped. Namespaces are prefix with "PHPUnit" except for "phpspec/prophecy". If your autoload is free of PHPUnit and Prophecy you should be fine and have no collision at all!

Conclusion (or TL;DR 😛)

If you have to autoload PHPUnit phar just require it!

Two PHPUnit phars are available: one to be used as a binary the other as a library. Choose the one matching your needs!

Awesome work from PHPUnit maintainers!