App from Scratch

Building a PHP

Conor Smith

I am a

Head of Engineering, Journal Media

If you wish to make apple pie tomato sauce from scratch,

you must first create the universe

Carl Sagan

Conor Smith

The Talk

Step-by-step through the application building process

How I structure my PHP applications

How I make my code interact with third-party packages

Caveat Emptor

Always

Write

Automated

Tests

PHP Dublin Monitor

Requirements

Public-facing website

Cron that polls PHP Dublin

Administration?

https://github.com/conorsmith/phpdublinmonitor

One Small Step


{
  "name": "conorsmith/phpdublinmonitor",
  "autoload": {
    "psr-4": {
      "ConorSmith\\PhpDublinMonitor\\": "src/"
    }
  }
}
/composer.json

/vendor
/.gitignore

The Entry Point


<?php
declare(strict_types=1);

require_once __DIR__ . "/bootstrap.php";
/console

<?php
declare(strict_types=1);

require_once __DIR__ . "/vendor/autoload.php";
/bootstrap.php

The Kernel


$ composer require symfony/console

namespace ConorSmith\PhpDublinMonitor\Console;

use Symfony\Component\Console\Application;

class Kernel
{
    public function handle(): void
    {
        $delegateKernel = new Application;
        $delegateKernel->run();
    }
}
/src/Console/Kernel.php

require_once __DIR__ . "/bootstrap.php";

(new ConorSmith\PhpDublinMonitor\Console\Kernel)->handle();
/console

The Running App


$ php console
Console Tool

Usage:
  command [options] [arguments]

Options:
  -h, --help            Display this help message
  -q, --quiet           Do not output any message
  -V, --version         Display this application version
      --ansi            Force ANSI output
      --no-ansi         Disable ANSI output
  -n, --no-interaction  Do not ask any interactive question
  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output,
 2 for more verbose output and 3 for debug

Available commands:
  help        Displays help for a command
  list        Lists commands

Directory Enquiry


src
  Console
    Kernel.php
vendor
  [...]
.gitignore
bootstrap.php
composer.json
composer.lock
console

Out with the New, in with the New


class Kernel
{
    /** @var Application */
    private $delegateKernel;

    public function __construct(Application $delegateKernel)
    {
        $this->delegateKernel = $delegateKernel;
    }

    public function handle(): void
    {
        $delegateKernel->run();
    }
}
/src/Console/Kernel.php

(new ConorSmith\PhpDublinMonitor\Console\Kernel(
    new Symfony\Component\Console\Application
))
    ->handle();
/console

Action!


namespace ConorSmith\PhpDublinMonitor\Console;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class LogWebsiteStatusAction extends Command
{
    protected function configure()
    {
        $this
            ->setName("log:status")
            ->setDescription(
                "Logs the current status of the PHP Dublin website"
            );
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $output->writeln("placeholder text");
    }
}
/src/Console/LogWebsiteStatusAction.php

Action!


$symfonyKernel = new Symfony\Component\Console\Application;

$symfonyKernel->add(
    new ConorSmith\PhpDublinMonitor\Console\LogWebsiteStatusAction
);

(new ConorSmith\PhpDublinMonitor\Console\Kernel(
    $symfonyKernel
))
    ->handle();
/console

$ php console log:status
placeholder text

Containing the Situation


namespace ConorSmith\PhpDublinMonitor;

use Illuminate\Contracts\Container\Container;

class Application
{
    /** @var Container */
    private $container;

    public function __construct(Container $container)
    {
        $this->container = $container;
        $this->registerServices();
    }

    private function registerServices(): void
    {
        //
    }

    public function make(string $className)
    {
        return $this->container->make($className);
    }
}
/src/Application.php

Containing the Situation


use ConorSmith\PhpDublinMonitor\Console\Kernel;
use ConorSmith\PhpDublinMonitor\Console\LogWebsiteStatusAction;
use Symfony\Component\Console\Application as SymfonyConsole;

class Application
{
    ...

    private function registerServices(): void
    {
        $this->container[Kernel::class] = function ($container) {
            $symfonyKernel = new SymfonyConsole;

            $symfonyKernel->add(new LogWebsiteStatusAction);

            return new Kernel($symfonyKernel);
        };
    }

    ...
}
/src/Application.php

Containing the Situation


require_once __DIR__ . "/vendor/autoload.php";

return new ConorSmith\PhpDublinMonitor\Application(
    new Illuminate\Container\Container
);
/bootstrap.php

$application = require_once __DIR__ . "/bootstrap.php";

$kernel = $application->make(
    ConorSmith\PhpDublinMonitor\Console\Kernel::class
);

$kernel->handle();
/console

Persistence Is Key


use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class LogWebsiteStatusAction extends Command
{
    /** @var Connection */
    private $db;

    public function __construct(Connection $db)
    {
        $this->db = $db;
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $this->db->insert("website_status", [
            'online' => true,
        ]);

        $output->writeln("Dummy data inserted");
    }

    ...
}
/src/Console/LogWebsiteStatusAction.php

Persistence Is Key


use ConorSmith\PhpDublinMonitor\Console\LogWebsiteStatusAction;
use Doctrine\DBAL\DriverManager;

class Application
{
    private function registerServices(): void
    {
        $this->container[Kernel::class] = function ($container) {
            ...
            $symfonyKernel->add($container[LogWebsiteStatusAction::class]);
            ...
        };

        $this->container[LogWebsiteStatusAction::class] = function () {
            return new LogWebsiteStatusAction(
                DriverManager::getConnection([
                    'driver'   => "pdo_mysql",
                    'host'     => getenv('DB_HOST'),
                    'dbname'   => getenv('DB_NAME'),
                    'user'     => getenv('DB_USER'),
                    'password' => getenv('DB_PASS'),
                ])
            );
        };
    }
}
/src/Application.php

Environmental Concerns


DB_HOST=localhost
DB_NAME=phpdublinmonitor
DB_USER=phpdublinmonitor
DB_PASS=password
/.env

/vendor
.env
/.gitignore

DB_HOST=
DB_NAME=
DB_USER=
DB_PASS=
/.env.example

Environmental Concerns


use Dotenv\Dotenv;

class Application
{
    public function __construct(Container $container)
    {
        $this->container = $container;
        $this->boot();
    }

    private function boot(): void
    {
        (new Dotenv(__DIR__ . "/../"))->load();
        $this->registerServices();
    }

    ...
}
/src/Application.php

Plotting a Schema


<?php
declare(strict_types=1);

require_once __DIR__ . "/vendor/autoload.php";

(new \Dotenv\Dotenv(__DIR__))->load();

return [

    'paths' => [
        'migrations' => __DIR__ . "/database/migrations",
    ],

    'environments' => [
        'default_database' => "env_vars",
        'env_vars'         => [
            'adapter' => "mysql",
            'host'    => getenv('DB_HOST'),
            'name'    => getenv('DB_NAME'),
            'user'    => getenv('DB_USER'),
            'pass'    => getenv('DB_PASS'),
        ],
    ],

];
/phinx.php

Plotting a Schema


<?php
declare(strict_types=1);

use Phinx\Migration\AbstractMigration;

class CreateWebsiteStatusTable extends AbstractMigration
{
    public function up()
    {
        $this->table('website_status')
            ->addColumn('online', "boolean")
            ->save();
    }
}
/database/migrations/..._created_website_status_table.php

A Matter of Time

use Doctrine\DBAL\Connection;
use Icecave\Chrono\Clock\ClockInterface;

class LogWebsiteStatusAction extends Command
{
    /** @var ClockInterface */
    private $clock;

    public function __construct(Connection $db, ClockInterface $clock)
    {
        $this->db = $db;
        $this->clock = $clock;
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $this->db->insert("website_status", [
            'online'    => true,
            'logged_at' => $this->clock->utcDateTime()->format("Y-m-d H:i:s"),
        ]);

        ...
    }

    ...
}
/src/Console/LogWebsiteStatusAction.php

A Matter of Time


public function up()
{
    $this->table('website_status')
        ->addColumn('logged_at', "datetime")
        ->save();
}
/database/migrations/..._add_logged_at_to_website_status_table.php

use Icecave\Chrono\Clock\SystemClock;

class Application
{
    private function registerServices(): void
    {
        $container[LogWebsiteStatusAction::class] = function ($container) {
            return new LogWebsiteStatusAction(
                DriverManager::getConnection([...]),
                new SystemClock
            );
        };

    }
}
/src/Application.php

Checking In


$ php console log:status
Dummy data inserted

mysql> select * from website_status;

+----+--------+---------------------+
| id | online | logged_at           |
+----+--------+---------------------+
|  1 |      1 | 2017-12-08 16:27:25 |
+----+--------+---------------------+
1 row in set (0.00 sec)

database
  migrations
    ..._create_website_status_table.php
    ..._add_logged_at_to_website_status_table.php
src
  Console
    Kernel.php
    LogWebsiteStatusAction.php
  Application.php
vendor
  ...
.env
.env.example
.gitignore
bootstrap.php
composer.json
composer.lock
console
phinx.php

At Your Service


class Application
{
    private function boot(): void
    {
        ...
        $this->registerServiceProviders();
    }

    private function registerServiceProviders(): void
    {
        (new Console\ServiceProvider)->register($this->container);
    }
}
/src/Application.php

use Illuminate\Contracts\Container\Container;

class ServiceProvider
{
    public function register(Container $container)
    {
        ...
    }
}
/src/Console/ServiceProvider.php

Command and Conquer


use Doctrine\DBAL\Connection;
use Icecave\Chrono\Clock\ClockInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/src/Console/LogWebsiteStatusAction.php

Command and Conquer


namespace ConorSmith\PhpDublinMonitor;

use Doctrine\DBAL\Connection;
use Icecave\Chrono\Clock\ClockInterface;

class LogWebsiteStatus
{
    /** @var Connection */
    private $db;

    /** @var ClockInterface */
    private $clock;

    public function __construct(Connection $db, ClockInterface $clock)
    {
        $this->db = $db;
        $this->clock = $clock;
    }

    public function __invoke(): void
    {
        $this->db->insert("website_status", [
            'online'    => true,
            'logged_at' => $this->clock->utcDateTime()->format("Y-m-d H:i:s"),
        ]);
    }
}
/src/LogWebsiteStatus.php

Command and Conquer


namespace ConorSmith\PhpDublinMonitor\Console;

use ConorSmith\PhpDublinMonitor\LogWebsiteStatus;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class LogWebsiteStatusAction extends Command
{
    /** @var LogWebsiteStatus */
    private $logWebsiteStatus;

    public function __construct(LogWebsiteStatus $logWebsiteStatus)
    {
        $this->logWebsiteStatus = $logWebsiteStatus;
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $this->logWebsiteStatus->__invoke();

        $output->writeln("Dummy data inserted");
    }
}
/src/Console/LogWebsiteStatusAction.php

Command and Conquer


private function registerServiceProviders(): void
{
    (new Console\ServiceProvider)->register($this->container);
    (new ServiceProvider)->register($this->container);
}
/src/Application.php

public function register(Container $container): void
{
    $container[LogWebsiteStatus::class] = function ($container) {
        return new LogWebsiteStatus(
            DriverManager::getConnection([
                'driver'   => "pdo_mysql",
                'host'     => getenv('DB_HOST'),
                'dbname'   => getenv('DB_NAME'),
                'user'     => getenv('DB_USER'),
                'password' => getenv('DB_PASS'),
            ]),
            new SystemClock
        );
    };
}
/src/ServiceProvider.php

Making a Connection


use GuzzleHttp\Client as Guzzle;

class ServiceProvider
{
    public function register(Container $container): void
    {
        $container[LogWebsiteStatus::class] = function ($container) {
            return new LogWebsiteStatus(
                new Guzzle([
                    'base_uri'    => "https://phpdublin.com/",
                    'http_errors' => false,
                ]),
                ...
            );
        };
    }
}
/src/ServiceProvider.php

Making a Connection


use GuzzleHttp\ClientInterface as Guzzle;

class LogWebsiteStatus
{
    /** @var Guzzle */
    private $guzzle;

    public function __invoke(): void
    {
        $response = $this->guzzle->request('GET', "/");

        $this->db->insert("website_status", [
            'online'    => $response->getStatusCode() === 200,
            ...
        ]);
    }
}
/src/LogWebsiteStatus.php

Making a Connection


use Teapot\StatusCode;

class LogWebsiteStatus
{
    /** @var Guzzle */
    private $guzzle;

    public function __invoke(): void
    {
        $response = $this->guzzle->request('GET', "/");

        $this->db->insert("website_status", [
            'online'    => $response->getStatusCode() === StatusCode::OK,
            ...
        ]);
    }
}
/src/LogWebsiteStatus.php

Checking In


$ php console log:status
PHP Dublin website status logged

database
  migrations
    ..._create_website_status_table.php
    ..._add_logged_at_to_website_status_table.php
src
  Console
    Kernel.php
    LogWebsiteStatusAction.php
    ServiceProvider.php
  Application.php
  LogWebsiteStatus.php
  ServiceProvider.php
vendor
  ...
.env
.env.example
.gitignore
bootstrap.php
composer.json
composer.lock
console
phinx.php

That Web Look


<?php
declare(strict_types=1);

echo "placeholder text";
/public/index.php

That Web Look


$application = require_once __DIR__ . "/../bootstrap.php";

$action = $application->make(
    ConorSmith\PhpDublinMonitor\Web\DisplayWebsiteStatusAction::class
);

$action();
/public/index.php

namespace ConorSmith\PhpDublinMonitor\Web;

class DisplayWebsiteStatusAction
{
    public function __invoke(): void
    {
        echo "placeholder text";
    }
}
/src/Web/DisplayWebsiteStatusAction.php

Non-Trivial Pursuits


use Doctrine\DBAL\Connection;

class DisplayWebsiteStatusAction
{
    /** @var Connection */
    private $db;

    public function __invoke(): void
    {
        $row = $this->db->fetchAssoc(
            "SELECT * FROM website_status ORDER BY logged_at DESC LIMIT 1"
        );

        echo sprintf(
            "The PHP Dublin website is <strong>%s</strong>",
            $row['online'] ? "online" : "offline"
        );
    }
}
/src/Web/DisplayWebsiteStatusAction.php

Non-Trivial Pursuits


class ServiceProvider
{
    public function register(Container $container): void
    {
        $container[DisplayWebsiteStatusAction::class] = function ($container) {
            return new DisplayWebsiteStatusAction(
                DriverManager::getConnection([
                    'driver'   => "pdo_mysql",
                    'host'     => getenv('DB_HOST'),
                    'dbname'   => getenv('DB_NAME'),
                    'user'     => getenv('DB_USER'),
                    'password' => getenv('DB_PASS'),
                ])
            );
        };
    }
}
/src/Web/ServiceProvider.php

private function registerServiceProviders(): void
{
    ...
    (new Web\ServiceProvider)->register($this->container);
}
/src/Application.php

Non-Trivial Pursuits

Too Many Connections


public function register(Container $container): void
{
    $container[LogWebsiteStatus::class] = function ($container) {
        return new LogWebsiteStatus(
            ...
            $container[Connection::class]
        );
    };

    $container->singleton(Connection::class, function ($container) {
        return DriverManager::getConnection([
            'driver'   => "pdo_mysql",
            'host'     => getenv('DB_HOST'),
            'dbname'   => getenv('DB_NAME'),
            'user'     => getenv('DB_USER'),
            'password' => getenv('DB_PASS'),
        ]);
    });
}
/src/ServiceProvider.php

Better Template Than Never


use League\Plates\Engine;

class DisplayWebsiteStatusAction
{
    /** @var Engine */
    private $templateEngine;

    public function __invoke(): void
    {
        ...

        echo $this->templateEngine->render("home", [
            'status' => $row['online'] ? "online" : "offline",
        ]);
    }
}
/src/Web/DisplayWebsiteStatusAction.php

Better Template Than Never


$container[DisplayWebsiteStatusAction::class] = function ($container) {
    return new DisplayWebsiteStatusAction(
        ...
        new Engine(__DIR__ . "/../../resources/templates")
    );
};
/src/Web/ServiceProvider.php

<div class="container">
    <p class="text-center" style="margin-top: 20px;">
        The PHP Dublin website is <strong><?=$status?></strong>
    </p>
</div>
/resources/templates/home.php

Better Template Than Never

Better Template Than Never


echo $this->templateEngine->render("home", [
    ...
    'lastUpdated' => DateTimeImmutable::createFromFormat(
        "Y-m-d H:i:s",
        $row['logged_at']
    ),
]);
/src/Web/DisplayWebsiteStatusAction.php

Route Planning


namespace ConorSmith\PhpDublinMonitor\Web;

use League\Route\RouteCollection;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;

class Kernel
{
    /** @var RouteCollection */
    private $routes;

    public function handle(): void
    {
        $this->routes->dispatch(
            ServerRequestFactory::fromGlobals(),
            new Response
        );
    }
}
/src/Web/Kernel.php

Route Planning


$container[RouteCollection::class] = function ($container) {
    $routes = new RouteCollection;

    $routes->map('GET', "/", $container[DisplayWebsiteStatusAction::class]);

    return $routes;
};
/src/Web/ServiceProvider.php

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

class DisplayWebsiteStatusAction
{
    public function __invoke(
        ServerRequestInterface $request,
        ResponseInterface $response
    ): ResponseInterface {
        ...
        return $response;
    }
}
/src/Web/DisplayWebsiteStatusAction.php

RSVP


use Zend\Diactoros\Response\EmitterInterface;

class Kernel
{
    /** @var EmitterInterface */
    private $emitter;

    public function handle(): void
    {
        $response = $this->routes->dispatch(...);

        $this->emitter->emit($response);
    }
}
/src/Web/Kernel.php

public function register(Container $container): void
{
    $container->bind(EmitterInterface::class, SapiEmitter::class);
}
/src/Web/ServiceProvider.php

RSVP


use Zend\Diactoros\Response\HtmlResponse;

class DisplayWebsiteStatusAction
{
    public function __invoke(
        ServerRequestInterface $request,
        ResponseInterface $response
    ): ResponseInterface {
        ...
    
        return new HtmlResponse(
            $this->templateEngine->render(...)
        );
    }
}
/src/Web/DisplayWebsiteStatusAction.php

Alternate Route


class DisplayHistoricStatusesAction
{
    public function __invoke(
        ServerRequestInterface $request,
        ResponseInterface $response
    ): ResponseInterface {
        $rows = $this->db->fetchAll(
            "SELECT * FROM website_status ORDER BY logged_at DESC LIMIT 10"
        );

        return new HtmlResponse(
            $this->templateEngine->render("historic", [
                'logEntries' => array_map(function (array $row) {
                    return [
                        'status'      => $row['online'] ? "online" : "offline",
                        'lastUpdated' => DateTimeImmutable::createFromFormat(
                            "Y-m-d H:i:s",
                            $row['logged_at']
                        ),
                    ];
                }, $rows),
            ])
        );
    }
}
/src/Web/DisplayHistoricStatusesAction.php

Alternate Route


$container[RouteCollection::class] = function ($container) {
    $routes = new RouteCollection;

    $routes->map(
        'GET',
        "/",
        $container[DisplayWebsiteStatusAction::class]
    );

    $routes->map(
        'GET',
        "/historic",
        $container[DisplayHistoricStatusesAction::class]
    );

    return $routes;
};
/src/Web/ServiceProvider.php

Alternate Route

Malcolm in the Middleware


$container[HttpBasicAuthentication::class] = function ($container) {
    return new HttpBasicAuthentication([
        'secure' => false,
        'users'  => [
            getenv('ADMIN_USER') => getenv('ADMIN_PASS'),
        ],
    ]);
};

$container[RouteCollection::class] = function ($container) {
    ...

    $routes->map(
        'GET',
        "/historic",
        $container[DisplayHistoricStatusesAction::class]
    )
        ->middleware($container[HttpBasicAuthentication::class]);

    ...
};
/src/Web/ServiceProvider.php

I Am Routes.php


$container[RouteCollection::class] = function ($container) {
    $routes = new RouteCollection;

    include __DIR__ . "/../../routes.php";

    return $routes;
};
/src/Web/ServiceProvider.php

namespace ConorSmith\PhpDublinMonitor\Web;

$routes->map(
    'GET',
    "/",
    $container[DisplayWebsiteStatusAction::class]
);

$routes->map(
    'GET',
    "/historic",
    $container[DisplayHistoricStatusesAction::class]
)
    ->middleware($container[HttpBasicAuthentication::class]);
/routes.php

Checking In


{
  "name": "conorsmith/phpdublinmonitor",
  "autoload": {
    "psr-4": {
      "ConorSmith\\PhpDublinMonitor\\": "src/"
    }
  },
  "require": {
    "symfony/console": "^3.3",
    "illuminate/container": "^5.5",
    "doctrine/dbal": "^2.6",
    "vlucas/phpdotenv": "^2.4",
    "robmorgan/phinx": "^0.9.1",
    "icecave/chrono": "^1.0",
    "guzzlehttp/guzzle": "^6.3",
    "shrikeh/teapot": "^2.3",
    "league/plates": "^3.3",
    "league/route": "^3.0",
    "zendframework/zend-diactoros": "^1.6",
    "tuupola/slim-basic-auth": "^2.3"
  }
}

C'est Tout

https://github.com/conorsmith/phpdublinmonitor

No framework

Remember the code is here:

Serves web pages

Queries a URL

Interacts with DB

Loads env vars

Manages migrations

PSR-7 compatible

Any Questions?

Journal Media is Hiring

Remember

Talk to me

Building a PHP App from Scratch

By Conor Smith

Building a PHP App from Scratch

PHP Dublin, December 12th 2017

  • 537
Loading comments...

More from Conor Smith