Tuesday 24 January 2023

Symfony: installing in my PHP8 container (for a second time, as it turns out)

G'day:

First up, I've messed around in the last coupla articles setting up some PHP8.2 containers (PHP: returning to PHP and setting up a PHP8 dev environment), adding MariaDB (Docker: adding a MariaDB container to my PHP & Nginx ones) etc and documenting it all… then I realised I've actually done this very exercise before! A coupla years ago when I was looking for PHP work and decided I had better get up to speed with Docker / Symfony / front-end dev. I had forgotten about a lot of it, only really remembering the VueJS part of it. Ha. Dammit. Oh well. Anyhow, that series - and it's def a series, there's a dozen articles - are all tagged with VueJs/Symfony/Docker/TDD series. Still, I am going to do a Symfony installation exercise again, cos I want it to be in this project this time. Because reasons.

Installing the baseline Symfony app

I'm working through Symfony › Installing & Setting up the Symfony Framework.

OK so I already have the Symfony CLI client installed during getting the PHP container up and running to my liking (first article linked-to above), and it seems happy:

/var/www# symfony check:requirement

Symfony Requirements Checker
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

> PHP is using the following php.ini file:
/usr/local/etc/php/php.ini

> Checking Symfony requirements:

....................................

[OK] Your system is ready to run Symfony projects
Note The command console can use a different php.ini file ~~~~ than the one used by your web server. Please check that both the console and the web server are using the same PHP version and configuration. /var/www#

(note I'm running all this in a shell on the container, not on my host machine. I do not have PHP or Composer or anything like that installed on the host machine)

I already know Symfony / Composer will shit itself if I try to install Symfony in a non-empty directory, so I'm going to run the installer in /var/tmp, and I'll re-integrate the files I need into my app directory by hand.

/var/tmp# symfony new my_project_directory --version="6.2.*" --no-git
* Creating a new Symfony 6.2.* project with Composer
  (running /usr/local/bin/composer create-project symfony/skeleton /var/tmp/my_project_directory 6.2.* --no-interaction)

[OK] Your project is now ready in /var/tmp/my_project_directory
/var/tmp#

Note the --no-git there. Without that the installer wants my Git identification otherwise it can't run git init, and I don't need it to do that anyhow, so skip that bit. I found this out via trial and error.

What's installed:

/var/tmp# tree -L 2
.
`-- my_project_directory
    |-- bin
    |-- composer.json
    |-- composer.lock
    |-- config
    |-- public
    |-- src
    |-- symfony.lock
    |-- var
    `-- vendor

7 directories, 3 files
/var/tmp#

BTW I cheated and installed tree without telling you:

/var/tmp# apt-get update
[…]
Reading package lists... Done
/var/tmp# apt-get install tree

Right so a lot of that will copy across fine into my app dir, except I'll need to rename my /var/www/html to be /var/www/public. I'll also need to merge this composer.json file with my own one, as with the .gitignore. I'll just get rid of the vendor directory as I can regenerate all that with composer update. I don't currently know what the symfony.lock file is, so I'll copy it across. I presume it's something along the lines of a Symfony version of composer.lock, and is generated somehow. I'll find out later I guess.

Symfony sets the "PSR-4" namespaces to be App\\ and App\\Tests\\. I'm not having my app called "App": that's ridiculous, plus it's actually invalid according to the PSR-4 standard anyhow!

2. Specification

  1. The term "class" refers to classes, interfaces, traits, and other similar structures.
  2. A fully qualified class name has the following form:
      \<NamespaceName>(\<SubNamespaceNames>)*\<ClassName>
      
    1. The fully qualified class name MUST have a top-level namespace name, also known as a "vendor namespace".
    2. The fully qualified class name MAY have one or more sub-namespace names.

PSR-4, section 2 (part)

They're only using the \<SubNamespaceNames>.

Hopefully this is just a matter of renaming some namespace references in whatever stub / config files it's installed. However I'll actually rename my test directory to be tests to match the Symfony-idiomatic naming standard there.

File changes to make Symfony work with an existing PHP project

There was surprisingly little to do. I'll run through them, point by point


Rename of html directory to public

To fit with Nginx's default settings, I had my webroot set to be html, ie: /var/www/html. Symfony uses public, so to change that, I had to make the following file tweaks:

# docker/docker-compose.yml
version: "3"
services:
  nginx:
    build:
      context: nginx
      dockerfile: Dockerfile

    ports:
      - "8008:80"

    stdin_open: true
    tty: true

    volumes:
      - ../html:/usr/share/nginx/html/
      - ../public:/usr/share/nginx/html/

    depends_on:
      - php

Note that as far as Nginx is concerned, its web root is still /usr/share/nginx/html/, I'm just attaching the /public directory on the host machine to provide its files.

Also I've added the depends_on there because I was finding Nginx was now starting before PHP was ready, so Nginx was exiting due to not finding PHP to proxy to.

# docker/nginx/sites/default.conf
server {
    # …

    location ~ \.php$ {
        try_files $uri /index.php =404;
        fastcgi_pass php-upstream;
        fastcgi_index index.php;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
        fastcgi_param SCRIPT_FILENAME /var/www/html/$fastcgi_script_name;
        fastcgi_param SCRIPT_FILENAME /var/www/public/$fastcgi_script_name;
        fastcgi_read_timeout 600;
        include fastcgi_params;
    }

    location ~ /\.ht {
        deny all;
    }
}

But I need to still pass the file from Nginx through to the /var/www/public/ directory now, in the PHP container.


.gitignore

/.idea
/vendor
/.phpunit.result.cache

###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
###< symfony/framework-bundle ###

###> phpunit/phpunit ###
/phpunit.xml
.phpunit.result.cache
###< phpunit/phpunit ###

A bunch of Symfony specific stuff. Seems inoccuous.


composer.json

{
    "name" : "adamcameron/php8",
    "description" : "PHP8 containers",
    "type" : "project",
    "license" : "LGPL-3.0-only",
    "require": {
        …
        "monolog/monolog": "^3.2.0",
        "symfony/console": "6.2.*",
        "symfony/dotenv": "6.2.*",
        "symfony/flex": "^2",
        "symfony/framework-bundle": "6.2.*",
        "symfony/http-client": "^6.2.2",
        "symfony/runtime": "6.2.*",
        "symfony/yaml": "6.2.*"
    },
    "require-dev": {
        "phpunit/phpunit": "^9.5.28",
        "phpmd/phpmd": "^2.13.0",
        "squizlabs/php_codesniffer": "^3.7.1"
    },
    "config": {
        "allow-plugins": {
            "symfony/flex": true,
            "symfony/runtime": true
        },
        "sort-packages": true
    },
    "autoload": {
        "psr-4": {
            "adamcameron\\php8\\": "src/",
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "adamcameron\\php8\\testtests\\": "testtests/"
        }
    },
    "replace": {
        "symfony/polyfill-ctype": "*",
        "symfony/polyfill-iconv": "*",
        "symfony/polyfill-php72": "*",
        "symfony/polyfill-php73": "*",
        "symfony/polyfill-php74": "*",
        "symfony/polyfill-php80": "*",
        "symfony/polyfill-php81": "*"
    },
    "scripts" : {
        "test": "phpunit --testdox testtests",
        "phpmd": "phpmd src,testtests text phpmd.xml",
        "phpcs": "phpcs src testtests",
        "test-all": [
            "@test",
            "@phpmd",
            "@phpcs"
        ],
        "auto-scripts": {
            "cache:clear": "symfony-cmd",
            "assets:install %PUBLIC_DIR%": "symfony-cmd"
        },
        "post-install-cmd": [
            "@auto-scripts"
        ],
        "post-update-cmd": [
            "@auto-scripts"
        ]
    },
    "conflict": {
        "symfony/symfony": "*"
    },
    "extra": {
        "symfony": {
            "allow-contrib": false,
            "require": "6.2.*"
        }
    }
}

Here I took the stuff from the Symfony-install-generated composer.json file and merged it into mine.

I needed to continue to use Symfony's App namespace too, as it has hard dependencies on being able to find src/Kernel.php via that namespace. Suck. All my own code will continue to use a proper, correctly-defined namespace.

There's also a bit of the rename of the test directory to tests in the autoload-dev section. I also had to similarly update phpunit.xml.dist and all the namespace references. As that's just adding an s about the place, I'll not bore you with all those changes. For the completeists: it's all in Github for you to look at.


.env and .env.test

# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
#  * .env                contains default values for the environment variables needed by the app
#  * .env.local          uncommitted file with local overrides
#  * .env.$APP_ENV       committed environment-specific defaults
#  * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration

###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=369a2db7c3f19ae9cad11dd95777674e
###< symfony/framework-bundle ###

and

# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots

Symfony stuff. I will need to put those APP_SECRET values out of the codebase and put them into environment variables. They're not very "secret" sitting around in source control like that. I'll find out what they're for first. I am also looking forward to finding out what a "panther error" is. Ooh I wonder if I can change that KERNEL_CLASS reference to point to adamcameron\php8\Kernel instead, and get rid of that App namespace? Will look into that (might read my own article from last time I did this to see what I did about this…?).


symfony.lock

{
    "phpunit/phpunit": {
        "version": "9.5",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "main",
            "version": "9.3",
            "ref": "a6249a6c4392e9169b87abf93225f7f9f59025e6"
        },
        "files": [
            ".env.test",
            "phpunit.xml.dist",
            "tests/bootstrap.php"
        ]
    },
    "squizlabs/php_codesniffer": {
        "version": "3.7",
        "recipe": {
            "repo": "github.com/symfony/recipes-contrib",
            "branch": "main",
            "version": "3.6",
            "ref": "1019e5c08d4821cb9b77f4891f8e9c31ff20ac6f"
        }
    },
    "symfony/console": {
        "version": "6.2",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "main",
            "version": "5.3",
            "ref": "da0c8be8157600ad34f10ff0c9cc91232522e047"
        },
        "files": [
            "bin/console"
        ]
    },
    "symfony/flex": {
        "version": "2.2",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "main",
            "version": "1.0",
            "ref": "146251ae39e06a95be0fe3d13c807bcf3938b172"
        },
        "files": [
            ".env"
        ]
    },
    "symfony/framework-bundle": {
        "version": "6.2",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "main",
            "version": "6.2",
            "ref": "af47254c5e4cd543e6af3e4508298ffebbdaddd3"
        },
        "files": [
            "config/packages/cache.yaml",
            "config/packages/framework.yaml",
            "config/preload.php",
            "config/routes/framework.yaml",
            "config/services.yaml",
            "public/index.php",
            "src/Controller/.gitignore",
            "src/Kernel.php"
        ]
    },
    "symfony/routing": {
        "version": "6.2",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "main",
            "version": "6.2",
            "ref": "e0a11b4ccb8c9e70b574ff5ad3dfdcd41dec5aa6"
        },
        "files": [
            "config/packages/routing.yaml",
            "config/routes.yaml"
        ]
    }
}

This does seem like a Symfony-specific composer.lock file. I wonder why it needs one of its own?


bin/console

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

use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;

if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
    throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}

require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

return function (array $context) {
    $kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);

    return new Application($kernel);
};

I wonder why I need a console shell script?


config directory

There's a bunch of stuff in here, some obvious, some less so, but seems to be standard frameworked-app config like routing and the like. I'll not bother repeating it here, as I have nothing to add - commentary-wise - about any of it. Go have a look on Github perhaps.


public/index.php

<?php

use App\Kernel;

require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

return function (array $context) {
    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

That's all that's needed in the public directory to load the app. Nice.


src/Kernel.php

<?php

namespace App;

use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;
}

I guess I will need to change this to be app specific at some point, otherwise I don't really know why it needs to be in my app's src directory instead of Symfony's. Time will tell.


src/Controller/.gitignore

It's created an empty .gitignore here. Unsure why. If I was to guess, there's a reference to it here…

config/routes.yaml
controllers:
    resource:
        path: ../src/Controller/
        namespace: App\Controller
    type: attribute

… and the app will error if the directory doesn't exist?


tests/bootstrap.php

<?php

use Symfony\Component\Dotenv\Dotenv;

require dirname(__DIR__).'/vendor/autoload.php';

if (file_exists(dirname(__DIR__).'/config/bootstrap.php')) {
    require dirname(__DIR__).'/config/bootstrap.php';
} elseif (method_exists(Dotenv::class, 'bootEnv')) {
    (new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}

I'm not actually loading a botostrap file in my phpunit.xml.dist yet as I didn't need one. I'm guessing I'll need this if I'm doing any functional tests that need the framework infrastructure?


tests/integration/SymfonyTest.php

This is my code, not Symfony's. I want a test to check the app is up and running. I'm checking I can curl the homepage, and get the Symfony splash screen.

<?php

namespace adamcameron\php8\tests\integration;

use GuzzleHttp\Client;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Response;

/** @testdox Tests of Symfony installation */
class SymfonyTest extends TestCase
{
    /** @testdox It serves the default welcome page after installation */
    public function testSymfonyWelcomeScreenDisplays()
    {

        $client = new Client([
            'base_uri' => 'http://nginx/'
        ]);

        $response = $client->get(
            "/",
            ['http_errors' => false]
        );
        $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode());

        $html = $response->getBody();
        $document = new \DOMDocument();
        $document->loadHTML($html, LIBXML_NOWARNING | LIBXML_NOERROR); // not ideal, but libxml can't handle the SVG in the Symfony logo

        $xpathDocument = new \DOMXPath($document);

        $hasTitle = $xpathDocument->query('/html/head/title[text() = "Welcome to Symfony!"]');
        $this->assertCount(1, $hasTitle);
    }
}

That's it. Everything else is just dealing with the rename of test to tests. I'm gonna push this lot and draw a line under this article, and then come back and see if I can get rid of that App namespace.

Righto.

--
Adam