Creating a composer project starter

Introduction

If you want to make your library to be used by other people there are 2 specific things you should do for your library to make it a success: documentation and easy to set up/migrate. If a developer wants to use your library the first thing they want to is just type in a command, maybe answer a few questions interactively in the terminal and see a working example.
If you require to setup all sorts of configuration (for example manually write a config file, manually create a database, etc.) there is a big chance the developer will not get a working example and will no longer try to use your library.

If you look at popular PHP frameworks, all of them have a create-project starter. So how do we make our own one?

What is composer create-project doing

So what happens if I run this?

composer create-project laravel/laravel myapp

Basically making this call will do this:

git clone https://github.com/laravel/laravel.git myapp
cd myapp
rm -rf .git
composer install --no-script
composer run post-root-package-install
composer run post-install-cmd
composer run post-create-project-cmd

If you look at the composer.json of laravel/laravel you can see that it will copy .env.example to .env and will generate a app key in the .env afterwards.

An example of composer create-project

So what is the big benefit? Why not just use git clone instead? One of the big advantages is that you can make a temporary console command that asks a few questions about setting up your application and make this command modify the composer.json etc.

For example I can create a console command that will be called on create-project:

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;

class StartProjectCommand extends Command
{
    protected function configure()
    {
        $this->setName('start-project')
            ->setDescription('Start a new project with options')
            ->addOption('setup', null, InputArgument::OPTIONAL, 'Project setup (minimal/recommended/maximum)')
            ->addOption('cms', null, InputArgument::OPTIONAL, 'Enable CMS')
            ->addOption('framework', null, InputArgument::OPTIONAL, 'Framework (Laravel/Symfony)')
            ->addOption('user-object', null, InputArgument::OPTIONAL, 'Default user object (yes/no)');
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $helper = $this->getHelper('question');

        $setup = $input->getOption('setup');
        $cms = $input->getOption('cms');
        $framework = $input->getOption('framework');
        $userObject = $input->getOption('user-object');
        if (!$setup) {
            $setupQuestion = new ChoiceQuestion(
                'Select the project setup (minimal/recommended/maximum): ',
                ['minimal', 'recommended', 'maximum'],
                'recommended'
            );
            $setup = $helper->ask($input, $output, $setupQuestion);
        }
        if ($cms === null) {
            $cmsQuestion = new ConfirmationQuestion(
                'Enable apie/cms? (Yes/No)',
                true
            );
            $cms = $helper->ask($input, $output, $cmsQuestion);
        }

        if (!$framework) {
            $frameworkQuestion = new ChoiceQuestion(
                'Select the framework (0: Laravel/1: Symfony): ',
                ['Laravel', 'Symfony'],
                'Symfony'
            );
            $framework = $helper->ask($input, $output, $frameworkQuestion);
        }

        if ($userObject === null) {
            $userQuestion = new ConfirmationQuestion('Do you want a default user object? (yes/no): ', true);
            $userObject = $helper->ask($input, $output, $userQuestion);
        }

        $output->writeln("Project setup: $setup");
        $output->writeln('Apie CMS: ' . ($cms ? 'yes' : 'no'));
        $output->writeln("Framework: $framework");
        $output->writeln("Default user object: " . ($userObject ? 'yes' : 'no'));
        
        // do something with installing and the selected configuration
    }
}

Now we create a bin/start-project file that could run this command.


#!/usr/bin/env php
<?php
if (file_exists(__DIR__.'/../vendor/autoload.php')) {
    require_once __DIR__ . '/../vendor/autoload.php';
} else {
    require_once __DIR__ . '/../autoload.php';
}

use Symfony\Component\Console\Application;
use Apie\ApieProjectStarter\ProjectStarterCommand;

$application = new Application();
$application->add(new ProjectStarterCommand());
$application->run();

We make sure we can run it directly from a terminal with chmod +x bin/start-project. As long it is not doing any file changes I can run bin/start-project start-project to call the console command. I can also provide options. If the options are not provided (yet), I ask them in the console command. Now the last step is to configure composer to run bin/start-project on creating a project.


{
    "name": "apie/apie-project-starter",
    "description": "create an apie project",
    "type": "library",
    "require": {
        "composer/composer": "2.*",
        "symfony/console": "6.*",
        "symfony/finder": "6.*",
        "twig/twig": "^3.7.1"
    },
    "license": "MIT",
    "autoload": {
        "psr-4": {
            "Apie\\ApieProjectStarter\\": "installer/"
        }
    },
    "scripts": {
        "post-create-project-cmd": [
            "@php bin/start-project start-project",
            "@composer update"
        ]
    },
    "require-dev": {
    }
}

Why the @composer update afterwards? I change the composer.json in my start-project console command but composer has only installed the packages required by the project starter. We need to run composer update again to update all changes (and to always get the latest changes as well)

Testing the project starter locally

The next issue was figuring out how to make sure we can test the project starter without having to push our code to github and run create-project again and again. Luckily I found out it is possible to run the project starter locally without having to push your code. You DO have to commit your code locally to make it work or composer create-project will ignore any uncommitted changes.

To make this work we need to make our local filesystem the current packagist repository instead of using packagist.org.
In the root of our project we create a packages.json that will be used by composer to find our local code changes:

{
  "package": {
    "name": "apie/apie-project-starter",
    "version": "1.0.0",
    "source": {
      "url": "./.git",
      "type": "git",
      "reference": "main"
    }
  }
}

To make sure we do not need to do a large terminal call I add a makefile that creates a test project inside the folder test-project.


clean-test-project:
	rm -rf test-project
test-project: clean-test-project
	COMPOSER_CACHE_DIR=/dev/null composer create-project --repository-url=./packages.json apie/apie-project-starter test-project --no-cache

Now if I run make test-project it will test the project starter inside the folder test-project. It will ask anything in the console command and replaces/installs files until it is done. I did experience issues with composer cache, so I disable the composer cache with COMPOSER_CACHE_DIR=/dev/null in the call to always get the latest local version.

Doing the actual installing work

So after the questions were filled in or provided as command arguments and making it testable I could finally run file modifications etc.

Locating composer.json

locating composer.json is easy as composer provide a few helper classes. I can call the method Composer\Factory::getComposerFile() to get the composer.json file location. I can just use json_encode and json_decode to modify the contents. It's advised to call json_encode with indenting when storing the new composer.json or you'll get a minified composer.json file. Example: json_encode($composerJsonContents, null, 4)

The root of the project can also be determined as this will be the same as dirname(Composer\Factory::getComposerFile()). This helps us with creating/modifying our files.

Creating files in the project

I use twig internally for creating the files to add. I look for all twig files in a folder and render these to a string and store them correctly. I use symfony/finder to find all files in a path as twig has no ability to give me all templates available. Twig also requires a cache folder, so I provide the future application cache path for this to be used as cache folder.


use Symfony\Component\Finder\Finder;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
final class TwigRender
{
    private Environment $twig;

    public function __construct(private readonly string $templatePath, private readonly string $cachePath)
    {
        $loader = new FilesystemLoader($this->templatePath);
        $this->twig = new Environment($loader, [
            'cache' => $this->cachePath,
        ]);
    }

    public function renderAll(string $targetPath): void
    {
        foreach (Finder::create()->files()->name('/.+.twig/')->ignoreDotFiles(false)->in($this->templatePath) as $file) {
            $templateFile = $file->getRelativePath() . DIRECTORY_SEPARATOR . $file->getBasename('.twig');
            $targetFile = $targetPath . DIRECTORY_SEPARATOR . $templateFile;
            @mkdir(dirname($targetFile), recursive: true);
            file_put_contents(
                $targetFile,
                $this->twig->render($templateFile . '.twig', ['apieVersion' => ProjectStarterCommand::APIE_VERSION_TO_INSTALL])
            );
        }
    }
}
  

Cleaning up

As last step I clean up the installer code. This was easy as I put all installer files in a installer path. And because I run composer update again it will also remove all installer-only dependencies. I think my installer still needs some improvements in the questioning part, but it does do wonders with the installing. I also want to integrate some code analysis tooling in the starter as I have a apie phpstan rules package that could be setup perfectly in the project starter. It makes it suprisingly easy to test if my library works exactly the same in Laravel as it does in Symfony and being able to just have it as configuration option is awesome.

Comments