update readme

This commit is contained in:
lubiana 2022-04-05 00:02:52 +02:00 committed by Andre Lubian
parent b12cf019e7
commit ab3227b75f
88 changed files with 7546 additions and 176 deletions

3
.gitignore vendored
View file

@ -2,3 +2,6 @@
**/.php-cs-fixer.cache **/.php-cs-fixer.cache
.idea/ .idea/
.vagrant/ .vagrant/
/identifier.sqlite
*.log
**/*.sqlite

View file

@ -1,3 +1,37 @@
# No Framework # Create a PHP application without a Framework
Hello and welcome to this tutorial with helps you in understanding how to write complex apps without the help of
a framework. This tutorial is not for people who have never written PHP before, you should at least have some
experience with object oriented PHP and be able to look at the official PHP-Documentation to figure out what
a function or class we are using does.
I often hear people talking about frameworks as a solution to all the problems that you have in software development.
But in my opinion its even worse to use a framework if you do not know what you are doing, because often are fighting
more against the framework than actually solving the problem you should be working on. Even if you know what you are
doing i think it is good to get to know how the frameworks you are using work under the hood and what challenges they
actually solve for you.
## Credit:
This tutorial is based on the great [tutorial by Patrick Louys](https://github.com/PatrickLouys/no-framework-tutorial).
My version is way more opiniated and uses some newer PHP features. But you should still check out his tutorial which is
still very great and helped me personally a lot in taking the next step in my knowledge about PHP development. There is
also an [amazon book](https://patricklouys.com/professional-php/) which expands on the topics covered in this tutorial.
## Getting started.
As I am using a fairly new version of PHP in this tutorial I have added a Vagrantfile to this tutorial. If you do not
have PHP8.1 installed on your computer you can use the following commands to try out all the examples:
```shell
vagrant up
vagrant ssh
cd app
```
I have exposed the port 1234 to be used in the VM, if you would like to use another one you are free to modify the
Vagrantfile.
[Start](01-front-controller.md) [Start](01-front-controller.md)

1
Vagrantfile vendored
View file

@ -17,6 +17,5 @@ Vagrant.configure("2") do |config|
echo -e 'zend_extension=xdebug\nxdebug.client_host=10.0.2.2\n' >> /etc/php/conf.d/tutorial.ini echo -e 'zend_extension=xdebug\nxdebug.client_host=10.0.2.2\n' >> /etc/php/conf.d/tutorial.ini
echo -e 'xdebug.client_port=9003\nxdebug.mode=debug\n' >> /etc/php/conf.d/tutorial.ini echo -e 'xdebug.client_port=9003\nxdebug.mode=debug\n' >> /etc/php/conf.d/tutorial.ini
echo -e 'zend.assertions=1\n' >> /etc/php/conf.d/tutorial.ini echo -e 'zend.assertions=1\n' >> /etc/php/conf.d/tutorial.ini
SHELL SHELL
end end

13
app/cli-config.php Normal file
View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\Console\ConsoleRunner;
use Lubian\NoFramework\Factory\FileSystemSettingsProvider;
use Lubian\NoFramework\Factory\SettingsContainerProvider;
$settingsProvider = new FileSystemSettingsProvider(__DIR__ . '/config/settings.php');
$container = (new SettingsContainerProvider($settingsProvider))->getContainer();
return ConsoleRunner::createHelperSet($container->get(EntityManagerInterface::class));

View file

@ -12,7 +12,8 @@
"middlewares/trailing-slash": "^2.0", "middlewares/trailing-slash": "^2.0",
"middlewares/whoops": "^2.0", "middlewares/whoops": "^2.0",
"erusev/parsedown": "^1.7", "erusev/parsedown": "^1.7",
"symfony/cache": "^6.0" "symfony/cache": "^6.0",
"doctrine/orm": "^2.11"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@ -33,8 +34,7 @@
"phpstan/extension-installer": "^1.1", "phpstan/extension-installer": "^1.1",
"phpstan/phpstan-strict-rules": "^1.1", "phpstan/phpstan-strict-rules": "^1.1",
"thecodingmachine/phpstan-strict-rules": "^1.0", "thecodingmachine/phpstan-strict-rules": "^1.0",
"mnapoli/hard-mode": "^0.3.0", "mnapoli/hard-mode": "^0.3.0"
"psalm/phar": "^4.22"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {
@ -43,7 +43,10 @@
} }
}, },
"scripts": { "scripts": {
"serve": "php -S 0.0.0.0:1234 -t public", "serve": [
"Composer\\Config::disableProcessTimeout",
"php -S 0.0.0.0:1234 -t public"
],
"phpstan": "./vendor/bin/phpstan analyze", "phpstan": "./vendor/bin/phpstan analyze",
"baseline": "./vendor/bin/phpstan analyze --generate-baseline", "baseline": "./vendor/bin/phpstan analyze --generate-baseline",
"check": "./vendor/bin/phpcs", "check": "./vendor/bin/phpcs",

1742
app/composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,10 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
use Doctrine\ORM\EntityManagerInterface;
use FastRoute\Dispatcher; use FastRoute\Dispatcher;
use Laminas\Diactoros\ResponseFactory; use Laminas\Diactoros\ResponseFactory;
use Lubian\NoFramework\Factory\DiactorosRequestFactory; use Lubian\NoFramework\Factory\DiactorosRequestFactory;
use Lubian\NoFramework\Factory\DoctrineEm;
use Lubian\NoFramework\Factory\PipelineProvider; use Lubian\NoFramework\Factory\PipelineProvider;
use Lubian\NoFramework\Factory\RequestFactory; use Lubian\NoFramework\Factory\RequestFactory;
use Lubian\NoFramework\Http\BasicEmitter; use Lubian\NoFramework\Http\BasicEmitter;
@ -12,6 +14,7 @@ use Lubian\NoFramework\Http\Pipeline;
use Lubian\NoFramework\Http\RoutedRequestHandler; use Lubian\NoFramework\Http\RoutedRequestHandler;
use Lubian\NoFramework\Http\RouteMiddleware; use Lubian\NoFramework\Http\RouteMiddleware;
use Lubian\NoFramework\Repository\CachedMarkdownPageRepo; use Lubian\NoFramework\Repository\CachedMarkdownPageRepo;
use Lubian\NoFramework\Repository\DoctrineMarkdownPageRepo;
use Lubian\NoFramework\Repository\MarkdownPageFilesystem; use Lubian\NoFramework\Repository\MarkdownPageFilesystem;
use Lubian\NoFramework\Repository\MarkdownPageRepo; use Lubian\NoFramework\Repository\MarkdownPageRepo;
use Lubian\NoFramework\Service\Time\Now; use Lubian\NoFramework\Service\Time\Now;
@ -31,21 +34,25 @@ use Symfony\Contracts\Cache\CacheInterface;
use function FastRoute\simpleDispatcher; use function FastRoute\simpleDispatcher;
return [ return [
// alias
Now::class => fn (SystemClockNow $n) => $n,
ResponseFactoryInterface::class => fn (ResponseFactory $rf) => $rf,
Emitter::class => fn (BasicEmitter $e) => $e,
MiddlewareInterface::class => fn (RouteMiddleware $r) => $r,
RoutedRequestHandler::class => fn (InvokerRoutedHandler $h) => $h,
RequestFactory::class => fn (DiactorosRequestFactory $rf) => $rf,
CacheInterface::class => fn (FilesystemAdapter $a) => $a,
MarkdownPageRepo::class => fn (CachedMarkdownPageRepo $r) => $r,
// Factories
ResponseInterface::class => fn (ResponseFactory $rf) => $rf->createResponse(), ResponseInterface::class => fn (ResponseFactory $rf) => $rf->createResponse(),
ServerRequestInterface::class => fn (RequestFactory $rf) => $rf->fromGlobals(), ServerRequestInterface::class => fn (RequestFactory $rf) => $rf->fromGlobals(),
Now::class => fn (SystemClockNow $n) => $n,
Renderer::class => fn (Mustache_Engine $e) => new MustacheRenderer($e), Renderer::class => fn (Mustache_Engine $e) => new MustacheRenderer($e),
MLF::class => fn (Settings $s) => new MLF($s->templateDir, ['extension' => $s->templateExtension]), MLF::class => fn (Settings $s) => new MLF($s->templateDir, ['extension' => $s->templateExtension]),
ME::class => fn (MLF $mfl) => new ME(['loader' => $mfl]), ME::class => fn (MLF $mfl) => new ME(['loader' => $mfl]),
ResponseFactoryInterface::class => fn (ResponseFactory $rf) => $rf,
Emitter::class => fn (BasicEmitter $e) => $e,
RoutedRequestHandler::class => fn (InvokerRoutedHandler $h) => $h,
MiddlewareInterface::class => fn (RouteMiddleware $r) => $r,
Dispatcher::class => fn () => simpleDispatcher(require __DIR__ . '/routes.php'), Dispatcher::class => fn () => simpleDispatcher(require __DIR__ . '/routes.php'),
RequestFactory::class => fn (DiactorosRequestFactory $rf) => $rf,
Pipeline::class => fn (PipelineProvider $p) => $p->getPipeline(), Pipeline::class => fn (PipelineProvider $p) => $p->getPipeline(),
CacheInterface::class => fn (FilesystemAdapter $a) => $a,
MarkdownPageFilesystem::class => fn (Settings $s) => new MarkdownPageFilesystem($s->pagesPath), MarkdownPageFilesystem::class => fn (Settings $s) => new MarkdownPageFilesystem($s->pagesPath),
CachedMarkdownPageRepo::class => fn (CacheInterface $c, MarkdownPageFilesystem $r) => new CachedMarkdownPageRepo($c, $r), CachedMarkdownPageRepo::class => fn (CacheInterface $c, MarkdownPageFilesystem $r, Settings $s) => new CachedMarkdownPageRepo($c, $r, $s),
MarkdownPageRepo::class => fn (MarkdownPageFilesystem $r) => $r, EntityManagerInterface::class => fn (DoctrineEm $f) => $f->create(),
]; ];

View file

@ -1,12 +1,13 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
use Lubian\NoFramework\Http\RouteMiddleware; use Lubian\NoFramework\Http\RouteMiddleware;
use Lubian\NoFramework\Middleware\CacheMiddleware;
use Middlewares\TrailingSlash; use Middlewares\TrailingSlash;
use Middlewares\Whoops; use Middlewares\Whoops;
return [ return [
Whoops::class, Whoops::class,
TrailingSlash::class, TrailingSlash::class,
\Lubian\NoFramework\Middleware\CacheMiddleware::class, CacheMiddleware::class,
RouteMiddleware::class, RouteMiddleware::class,
]; ];

View file

@ -8,7 +8,8 @@ use Psr\Http\Message\ResponseInterface as Response;
return function (RouteCollector $r): void { return function (RouteCollector $r): void {
$r->addRoute('GET', '/hello[/{name}]', Hello::class); $r->addRoute('GET', '/hello[/{name}]', Hello::class);
$r->addRoute('GET', '/page/{page}', Page::class); $r->addRoute('GET', '/page', [Page::class, 'list']);
$r->addRoute('GET', '/page/{page}', [Page::class, 'show']);
$r->addRoute('GET', '/another-route', [Other::class, 'someFunctionName']); $r->addRoute('GET', '/another-route', [Other::class, 'someFunctionName']);
$r->addRoute('GET', '/', fn (Response $r) => $r->withStatus(302)->withHeader('Location', '/hello')); $r->addRoute('GET', '/', fn (Response $r) => $r->withStatus(302)->withHeader('Location', '/hello'));
}; };

View file

@ -3,10 +3,21 @@
use Lubian\NoFramework\Settings; use Lubian\NoFramework\Settings;
return new Settings( return new Settings(
environment: 'dev', environment: 'prod',
dependenciesFile: __DIR__ . '/dependencies.php', dependenciesFile: __DIR__ . '/dependencies.php',
middlewaresFile: __DIR__ . '/middlewares.php', middlewaresFile: __DIR__ . '/middlewares.php',
templateDir: __DIR__ . '/../templates', templateDir: __DIR__ . '/../templates',
templateExtension: '.html', templateExtension: '.html',
pagesPath: __DIR__ . '/../data/pages/' pagesPath: __DIR__ . '/../data/pages/',
connection: [
'driver' => 'pdo_sqlite',
'user' => '',
'password' => '',
'path' => __DIR__ . '/../data/db.sqlite',
],
doctrine: [
'devMode' => true,
'metadataDirs' => [__DIR__ . '/../src/Model/'],
'cacheDir' => __DIR__ . '/../data/cache/',
],
); );

View file

@ -2,6 +2,8 @@
namespace Lubian\NoFramework\Action; namespace Lubian\NoFramework\Action;
use Lubian\NoFramework\Model\MarkdownPage;
use Lubian\NoFramework\Repository\MarkdownPageFilesystem;
use Lubian\NoFramework\Repository\MarkdownPageRepo; use Lubian\NoFramework\Repository\MarkdownPageRepo;
use Lubian\NoFramework\Template\Renderer; use Lubian\NoFramework\Template\Renderer;
use Parsedown; use Parsedown;
@ -12,19 +14,33 @@ use function str_replace;
class Page class Page
{ {
public function __invoke( public function __construct(
private ResponseInterface $response,
private MarkdownPageRepo $repo,
private Parsedown $parsedown,
private Renderer $renderer,
){}
public function show(
string $page, string $page,
ResponseInterface $response,
MarkdownPageRepo $repo,
Parsedown $parsedown,
Renderer $renderer,
): ResponseInterface { ): ResponseInterface {
$page = $repo->byTitle($page); $page = $this->repo->byTitle($page);
$content = $this->linkFilter($page->content); $content = $this->linkFilter($page->content);
$content = $parsedown->parse($content); $content = $this->parsedown->parse($content);
$html = $renderer->render('page', ['content' => $content]); $html = $this->renderer->render('page', ['content' => $content, 'title' => $page->title]);
$response->getBody()->write($html); $this->response->getBody()->write($html);
return $response; return $this->response;
}
public function list(): ResponseInterface
{
$pages = array_map(
fn (MarkdownPage $p) => ['title' => $p->title, 'id' => $p->id],
$this->repo->all()
);
$html = $this->renderer->render('pagelist', ['pages' => $pages]);
$this->response->getBody()->write($html);
return $this->response;
} }
private function linkFilter(string $content): string private function linkFilter(string $content): string

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
use Doctrine\ORM\Tools\Setup;
use Lubian\NoFramework\Settings;
final class DoctrineEm
{
public function __construct(private Settings $settings){}
public function create(): EntityManagerInterface
{
$config = Setup::createConfiguration($this->settings->doctrine['devMode']);
$config->setMetadataDriverImpl(
new AttributeDriver(
$this->settings->doctrine['metadataDirs']
)
);
return EntityManager::create(
$this->settings->connection,
$config,
);
}
}

View file

@ -18,8 +18,9 @@ final class SettingsContainerProvider implements ContainerProvider
$builder = new ContainerBuilder; $builder = new ContainerBuilder;
$settings = $this->settingsProvider->getSettings(); $settings = $this->settingsProvider->getSettings();
$dependencies = require $settings->dependenciesFile; $dependencies = require $settings->dependenciesFile;
$dependencies[Settings::class] = fn (): Settings => $settings; $dependencies[Settings::class] = $settings;
$builder->addDefinitions($dependencies); $builder->addDefinitions($dependencies);
// $builder->enableCompilation('/tmp');
return $builder->build(); return $builder->build();
} }
} }

View file

@ -3,6 +3,7 @@
namespace Lubian\NoFramework\Middleware; namespace Lubian\NoFramework\Middleware;
use Laminas\Diactoros\Response; use Laminas\Diactoros\Response;
use Lubian\NoFramework\Settings;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\MiddlewareInterface;
@ -10,23 +11,30 @@ use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
use function base64_encode;
final class CacheMiddleware implements MiddlewareInterface final class CacheMiddleware implements MiddlewareInterface
{ {
public function __construct(private CacheInterface $cache){} public function __construct(
private CacheInterface $cache,
private Response\Serializer $serializer,
private Settings $settings,
)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{ {
if ($request->getMethod() === 'GET') { if ($request->getMethod() === 'GET' && !$this->settings->isDev()) {
$key = (string) $request->getUri(); $key = (string) $request->getUri();
$key = base64_encode($key); $key = base64_encode($key);
$callback = fn () => $handler->handle($request); $callback = fn () => $handler->handle($request);
$response = new Response(); $cached = $this->cache->get($key, function (ItemInterface $item) use ($callback) {
$body = $this->cache->get($key, function (ItemInterface $item) use ($callback) {
$item->expiresAfter(120); $item->expiresAfter(120);
return (string) $callback()->getBody(); $response = $callback();
return $this->serializer::toString($response);
}); });
$response->getBody()->write($body); return $this->serializer::fromString($cached);
return $response;
} }
return $handler->handle($request); return $handler->handle($request);
} }

View file

@ -2,12 +2,22 @@
namespace Lubian\NoFramework\Model; namespace Lubian\NoFramework\Model;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
#[Entity]
class MarkdownPage class MarkdownPage
{ {
public function __construct( public function __construct(
public readonly int $id, #[Id, Column, GeneratedValue]
public readonly string $title, public int|null $id = null,
public readonly string $content, #[Column]
public string $title,
#[Column(type: Types::TEXT)]
public string $content,
) { ) {
} }
} }

View file

@ -3,14 +3,16 @@
namespace Lubian\NoFramework\Repository; namespace Lubian\NoFramework\Repository;
use Lubian\NoFramework\Model\MarkdownPage; use Lubian\NoFramework\Model\MarkdownPage;
use Lubian\NoFramework\Settings;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
class CachedMarkdownPageRepo implements MarkdownPageRepo class CachedMarkdownPageRepo implements MarkdownPageRepo
{ {
public function __construct( public function __construct(
private CacheInterface $cache, private readonly CacheInterface $cache,
private MarkdownPageRepo $repo, private readonly MarkdownPageRepo $repo,
private readonly Settings $settings,
) { ) {
} }
@ -20,6 +22,9 @@ class CachedMarkdownPageRepo implements MarkdownPageRepo
public function all(): array public function all(): array
{ {
$callback = fn () => $this->repo->all(); $callback = fn () => $this->repo->all();
if ($this->settings->isDev()) {
return $callback();
}
return $this->cache->get('ALLPAGES', function (ItemInterface $item) use ($callback) { return $this->cache->get('ALLPAGES', function (ItemInterface $item) use ($callback) {
$item->expiresAfter(30); $item->expiresAfter(30);
return $callback(); return $callback();
@ -29,6 +34,9 @@ class CachedMarkdownPageRepo implements MarkdownPageRepo
public function byId(int $id): MarkdownPage public function byId(int $id): MarkdownPage
{ {
$callback = fn () => $this->repo->byId($id); $callback = fn () => $this->repo->byId($id);
if ($this->settings->isDev()) {
return $callback();
}
return $this->cache->get('PAGE' . $id, function (ItemInterface $item) use ($callback) { return $this->cache->get('PAGE' . $id, function (ItemInterface $item) use ($callback) {
$item->expiresAfter(30); $item->expiresAfter(30);
return $callback(); return $callback();
@ -38,9 +46,17 @@ class CachedMarkdownPageRepo implements MarkdownPageRepo
public function byTitle(string $title): MarkdownPage public function byTitle(string $title): MarkdownPage
{ {
$callback = fn () => $this->repo->byTitle($title); $callback = fn () => $this->repo->byTitle($title);
if ($this->settings->isDev()) {
return $callback();
}
return $this->cache->get('PAGE' . $title, function (ItemInterface $item) use ($callback) { return $this->cache->get('PAGE' . $title, function (ItemInterface $item) use ($callback) {
$item->expiresAfter(30); $item->expiresAfter(30);
return $callback(); return $callback();
}); });
} }
public function save(MarkdownPage $page): MarkdownPage
{
return $this->repo->save($page);
}
} }

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Lubian\NoFramework\Repository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Lubian\NoFramework\Exception\NotFound;
use Lubian\NoFramework\Model\MarkdownPage;
final class DoctrineMarkdownPageRepo implements MarkdownPageRepo
{
/**
* @var EntityRepository<MarkdownPage>
*/
private EntityRepository $repo;
public function __construct(
private EntityManagerInterface $entityManager
){
$this->repo = $this->entityManager->getRepository(MarkdownPage::class);
}
/**
* @inheritDoc
*/
public function all(): array
{
usleep(rand(500, 1500) * 1000);
return $this->repo->findAll();
}
public function byId(int $id): MarkdownPage
{
usleep(rand(500, 1500) * 1000);
$page = $this->repo->findOneBy(['id' => $id]);
if (!$page instanceof MarkdownPage){
throw new NotFound;
}
return $page;
}
public function byTitle(string $title): MarkdownPage
{
usleep(rand(500, 1500) * 1000);
$page = $this->repo->findOneBy(['title' => $title]);
if (!$page instanceof MarkdownPage){
throw new NotFound;
}
return $page;
}
public function save(MarkdownPage $page): MarkdownPage
{
$this->entityManager->persist($page);
$this->entityManager->flush();
return $page;
}
}

View file

@ -31,7 +31,7 @@ final class MarkdownPageFilesystem implements MarkdownPageRepo
$fileNames = glob($this->dataPath . '*.md'); $fileNames = glob($this->dataPath . '*.md');
assert(is_array($fileNames)); assert(is_array($fileNames));
return array_map(function (string $name): MarkdownPage { return array_map(function (string $name): MarkdownPage {
usleep(100000); usleep(rand(200, 500) * 1000);
$content = file_get_contents($name); $content = file_get_contents($name);
$name = str_replace($this->dataPath, '', $name); $name = str_replace($this->dataPath, '', $name);
$name = str_replace('.md', '', $name); $name = str_replace('.md', '', $name);
@ -60,4 +60,9 @@ final class MarkdownPageFilesystem implements MarkdownPageRepo
} }
return $filtered[0]; return $filtered[0];
} }
public function save(MarkdownPage $page): MarkdownPage
{
return $page;
}
} }

View file

@ -14,4 +14,6 @@ interface MarkdownPageRepo
public function byId(int $id): MarkdownPage; public function byId(int $id): MarkdownPage;
public function byTitle(string $title): MarkdownPage; public function byTitle(string $title): MarkdownPage;
public function save(MarkdownPage $page): MarkdownPage;
} }

View file

@ -4,6 +4,10 @@ namespace Lubian\NoFramework;
final class Settings final class Settings
{ {
/**
* @param array{driver: string, user: string, password: string, path: string} $connection
* @param array{devMode: bool, metadataDirs: string[], cacheDir: string} $doctrine
*/
public function __construct( public function __construct(
public readonly string $environment, public readonly string $environment,
public readonly string $dependenciesFile, public readonly string $dependenciesFile,
@ -11,6 +15,19 @@ final class Settings
public readonly string $templateDir, public readonly string $templateDir,
public readonly string $templateExtension, public readonly string $templateExtension,
public readonly string $pagesPath, public readonly string $pagesPath,
/**
* @var array{driver: string, user: string, password: string, path: string}
*/
public readonly array $connection,
/**
* @var array{devMode: bool, metadataDirs: string[], cacheDir: string}
*/
public readonly array $doctrine,
) { ) {
} }
public function isDev(): bool
{
return $this->environment === 'dev';
}
} }

View file

@ -0,0 +1,11 @@
{{> partials/head }}
<div class="column col-6 col-mx-auto">
<ul>
{{#pages}}
<li>
<a href="/page/{{title}}">{{id}}: {{title}}</a>
</li>
{{/pages}}
</ul>
</div>
{{> partials/foot }}

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Title</title> <title>No Framework: {{title}}</title>
<link rel="stylesheet" href="/css/spectre.min.css"> <link rel="stylesheet" href="/css/spectre.min.css">
<link rel="stylesheet" href="/css/spectre-exp.min.css"> <link rel="stylesheet" href="/css/spectre-exp.min.css">
<link rel="stylesheet" href="/css/spectre-icons.min.css"> <link rel="stylesheet" href="/css/spectre-icons.min.css">

View file

@ -0,0 +1,38 @@
<?php declare(strict_types=1);
/*
* This document has been generated with
* https://mlocati.github.io/php-cs-fixer-configurator/#version:3.1.0|configurator
* you can change this configuration by importing this file.
*/
$config = new PhpCsFixer\Config();
return $config
->setRiskyAllowed(true)
->setRules([
'@PSR12:risky' => true,
'@PSR12' => true,
'@PHP80Migration' => true,
'@PHP80Migration:risky' => true,
'@PHP81Migration' => true,
'array_indentation' => true,
'include' => true,
'blank_line_after_opening_tag' => false,
'native_constant_invocation' => true,
'new_with_braces' => false,
'native_function_invocation' => [
'include' => ['@all']
],
'no_unused_imports' => true,
'global_namespace_import' => [
'import_classes' => true,
'import_constants' => true,
'import_functions' => true,
],
'ordered_interfaces' => true,
])
->setFinder(
PhpCsFixer\Finder::create()
->in([
__DIR__ . '/src',
__DIR__ . '/config'
])
);

View file

@ -0,0 +1,9 @@
<?xml version="1.0"?>
<ruleset>
<arg name="basepath" value="."/>
<file>src</file>
<file>config</file>
<rule ref="HardMode"/>
</ruleset>

View file

@ -0,0 +1,54 @@
{
"name": "lubian/no-framework",
"require": {
"php": "^8.1",
"filp/whoops": "^2.14",
"laminas/laminas-diactoros": "^2.8",
"nikic/fast-route": "^1.3",
"psr/http-server-handler": "^1.0",
"php-di/php-di": "^6.3",
"mustache/mustache": "^2.14",
"psr/http-server-middleware": "^1.0",
"middlewares/trailing-slash": "^2.0",
"middlewares/whoops": "^2.0",
"erusev/parsedown": "^1.7",
"symfony/cache": "^6.0"
},
"autoload": {
"psr-4": {
"Lubian\\NoFramework\\": "src/"
}
},
"authors": [
{
"name": "lubian",
"email": "test@example.com"
}
],
"require-dev": {
"phpstan/phpstan": "^1.5",
"php-cs-fixer/shim": "^3.8",
"symfony/var-dumper": "^6.0",
"squizlabs/php_codesniffer": "^3.6",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan-strict-rules": "^1.1",
"thecodingmachine/phpstan-strict-rules": "^1.0",
"mnapoli/hard-mode": "^0.3.0"
},
"config": {
"allow-plugins": {
"phpstan/extension-installer": true,
"dealerdirect/phpcodesniffer-composer-installer": true
}
},
"scripts": {
"serve": [
"Composer\\Config::disableProcessTimeout",
"php -S 0.0.0.0:1234 -t public"
],
"phpstan": "./vendor/bin/phpstan analyze",
"baseline": "./vendor/bin/phpstan analyze --generate-baseline",
"check": "./vendor/bin/phpcs",
"fix": "./vendor/bin/php-cs-fixer fix && ./vendor/bin/phpcbf"
}
}

2273
implementation/16-caching/composer.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,54 @@
<?php declare(strict_types=1);
use FastRoute\Dispatcher;
use Laminas\Diactoros\ResponseFactory;
use Lubian\NoFramework\Factory\DiactorosRequestFactory;
use Lubian\NoFramework\Factory\PipelineProvider;
use Lubian\NoFramework\Factory\RequestFactory;
use Lubian\NoFramework\Http\BasicEmitter;
use Lubian\NoFramework\Http\Emitter;
use Lubian\NoFramework\Http\InvokerRoutedHandler;
use Lubian\NoFramework\Http\Pipeline;
use Lubian\NoFramework\Http\RoutedRequestHandler;
use Lubian\NoFramework\Http\RouteMiddleware;
use Lubian\NoFramework\Repository\CachedMarkdownPageRepo;
use Lubian\NoFramework\Repository\MarkdownPageFilesystem;
use Lubian\NoFramework\Repository\MarkdownPageRepo;
use Lubian\NoFramework\Service\Time\Now;
use Lubian\NoFramework\Service\Time\SystemClockNow;
use Lubian\NoFramework\Settings;
use Lubian\NoFramework\Template\MustacheRenderer;
use Lubian\NoFramework\Template\Renderer;
use Mustache_Engine as ME;
use Mustache_Loader_FilesystemLoader as MLF;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Contracts\Cache\CacheInterface;
use function FastRoute\simpleDispatcher;
return [
// alias
Now::class => fn (SystemClockNow $n) => $n,
ResponseFactoryInterface::class => fn (ResponseFactory $rf) => $rf,
Emitter::class => fn (BasicEmitter $e) => $e,
MiddlewareInterface::class => fn (RouteMiddleware $r) => $r,
RoutedRequestHandler::class => fn (InvokerRoutedHandler $h) => $h,
RequestFactory::class => fn (DiactorosRequestFactory $rf) => $rf,
CacheInterface::class => fn (FilesystemAdapter $a) => $a,
MarkdownPageRepo::class => fn (MarkdownPageFilesystem $r) => $r,
// Factories
ResponseInterface::class => fn (ResponseFactory $rf) => $rf->createResponse(),
ServerRequestInterface::class => fn (RequestFactory $rf) => $rf->fromGlobals(),
Renderer::class => fn (Mustache_Engine $e) => new MustacheRenderer($e),
MLF::class => fn (Settings $s) => new MLF($s->templateDir, ['extension' => $s->templateExtension]),
ME::class => fn (MLF $mfl) => new ME(['loader' => $mfl]),
Dispatcher::class => fn () => simpleDispatcher(require __DIR__ . '/routes.php'),
Pipeline::class => fn (PipelineProvider $p) => $p->getPipeline(),
MarkdownPageFilesystem::class => fn (Settings $s) => new MarkdownPageFilesystem($s->pagesPath),
CachedMarkdownPageRepo::class => fn (CacheInterface $c, MarkdownPageFilesystem $r) => new CachedMarkdownPageRepo($c, $r),
];

View file

@ -0,0 +1,13 @@
<?php declare(strict_types=1);
use Lubian\NoFramework\Http\RouteMiddleware;
use Lubian\NoFramework\Middleware\CacheMiddleware;
use Middlewares\TrailingSlash;
use Middlewares\Whoops;
return [
Whoops::class,
TrailingSlash::class,
CacheMiddleware::class,
RouteMiddleware::class,
];

View file

@ -0,0 +1,14 @@
<?php declare(strict_types=1);
use FastRoute\RouteCollector;
use Lubian\NoFramework\Action\Hello;
use Lubian\NoFramework\Action\Other;
use Lubian\NoFramework\Action\Page;
use Psr\Http\Message\ResponseInterface as Response;
return function (RouteCollector $r): void {
$r->addRoute('GET', '/hello[/{name}]', Hello::class);
$r->addRoute('GET', '/page/{page}', Page::class);
$r->addRoute('GET', '/another-route', [Other::class, 'someFunctionName']);
$r->addRoute('GET', '/', fn (Response $r) => $r->withStatus(302)->withHeader('Location', '/hello'));
};

View file

@ -0,0 +1,12 @@
<?php declare(strict_types=1);
use Lubian\NoFramework\Settings;
return new Settings(
environment: 'dev',
dependenciesFile: __DIR__ . '/dependencies.php',
middlewaresFile: __DIR__ . '/middlewares.php',
templateDir: __DIR__ . '/../templates',
templateExtension: '.html',
pagesPath: __DIR__ . '/../data/pages/'
);

View file

@ -0,0 +1,53 @@
[next >>](02-composer.md)
### Front Controller
A [front controller](http://en.wikipedia.org/wiki/Front_Controller_pattern) is a single point of entry for your application.
To start, create an empty directory for your project. You also need an entry point where all requests will go to. This means you will have to create an `index.php` file.
A common way to do this is to just put the `index.php` in the root folder of the projects. This is also how some frameworks do it. Let me explain why you should not do this.
The `index.php` is the starting point, so it has to be inside the web server directory. This means that the web server has access to all subdirectories. If you set things up properly, you can still prevent it from accessing your subfolders where your application files are.
But sometimes things don't go according to plan. And if something goes wrong and your files are set up as above, your whole application source code could be exposed to visitors. I won't have to explain why this is not a good thing.
So instead of doing that, create a folder in your project folder called `public`. This is a good time to create an `src` folder for your application, also in the project root folder.
Inside the `public` folder you can now create your `index.php`. Remember that you don't want to expose anything here, so put just the following code in there:
```php
<?php declare(strict_types=1);
require __DIR__ . '/../src/Bootstrap.php';
```
`__DIR__` is a [magic constant](http://php.net/manual/en/language.constants.predefined.php) that contains the path of the directory. By using it, you can make sure that the `require` always uses the same relative path to the file it is used in. Otherwise, if you call the `index.php` from a different folder it will not find the file.
`declare(strict_types = 1);` sets the current file to [strict typing](http://php.net/manual/en/functions.arguments.php#functions.arguments.type-declaration.strict). In this tutorial we are going to use this for all PHP files. This means that you can't just pass an integer as a parameter to a method that requires a string. If you don't use strict mode, it would be automatically casted to the required type. With strict mode, it will throw an Exception if it is the wrong type.
The `Bootstrap.php` will be the file that wires your application together. We will get to it shortly.
The rest of the public folder is reserved for your public asset files (like JavaScript files and stylesheets).
Now navigate inside your `src` folder and create a new `Bootstrap.php` file with the following content:
```php
<?php declare(strict_types=1);
echo 'Hello World!';
```
Now let's see if everything is set up correctly. Open up a console and navigate into your projects `public` folder. In there type `php -S 0.0.0.0:1234` and press enter. This will start the built-in webserver and you can access your page in a browser with `http://localhost:1234`. You should now see the 'hello world' message.
If there is an error, go back and try to fix it. If you only see a blank page, check the console window where the server is running for errors.
Now would be a good time to commit your progress. If you are not already using Git, set up a repository now. This is not a Git tutorial so I won't go over the details. But using version control should be a habit, even if it is just for a tutorial project like this.
Some editors and IDE's put their own files into your project folders. If that is the case, create a `.gitignore` file in your project root and exclude the files/directories. Below is an example for PHPStorm:
```
.idea/
```
[next >>](02-composer.md)

View file

@ -0,0 +1,75 @@
[<< previous](01-front-controller.md) | [next >>](03-error-handler.md)
### Composer
[Composer](https://getcomposer.org/) is a dependency manager for PHP.
Just because you are not using a framework does not mean you will have to reinvent the wheel every time you want to do
something. With Composer, you can install third-party libraries for your application.
If you don't have Composer installed already, head over to the website and install it. You can find Composer packages
for your project on [Packagist](https://packagist.org/).
Create a new file in your project root folder called `composer.json`. This is the Composer configuration file that will
be used to configure your project and its dependencies. It must be valid JSON or Composer will fail.
Add the following content to the file:
```json
{
"name": "lubian/no-framework",
"require": {
"php": "^8.1"
},
"autoload": {
"psr-4": {
"Lubian\\NoFramework\\": "src/"
}
},
"authors": [
{
"name": "lubiana",
"email": "lubiana@hannover.ccc.de"
}
]
}
```
In the autoload part you can see that I am using the `Lubian\NoFramework` namespace for the project. You can use
whatever fits your project there, but from now on I will always use the `Lubian\NoFramework` namespace in my examples.
Just replace it with your namespace in your own code.
I have also defined, that all my code and classes in the 'Lubian\NoFramework' namespace lives under the './src' folder.
As the Bootstrap.php file is placed in that directory we should
add the namespace to the File as well. Here is my current Bootstrap.php
as a reference:
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework;
echo 'Hello World!';
```
Open a new console window and navigate into your project root folder. There run `composer update`.
Composer creates a `composer.lock` file that locks in your dependencies and a vendor directory.
Committing the `composer.lock` file into version control is generally good practice for projects. It allows
continuation testing tools (such as [Travis CI](https://travis-ci.org/)) to run the tests against the exact same
versions of libraries that you're developing against. It also allows all people who are working on the project to use
the exact same version of libraries i.e. it eliminates a source of "works on my machine" problems.
That being said, [you don't want to put the actual source code of your dependencies in your git repository](https://getcomposer.org/doc/faqs/should-i-commit-the-dependencies-in-my-vendor-directory.md). So let's add a rule to our `.gitignore` file:
```
vendor/
```
Now you have successfully created an empty playground which you can use to set up your project.
[<< previous](01-front-controller.md) | [next >>](03-error-handler.md)

View file

@ -0,0 +1,79 @@
[<< previous](02-composer.md) | [next >>](04-development-helpers.md)
### Error Handler
An error handler allows you to customize what happens if your code results in an error.
A nice error page with a lot of information for debugging goes a long way during development. So the first package
for your application will take care of that.
I like [filp/whoops](https://github.com/filp/whoops), so I will show how you can install that package for your project.
If you prefer another package, feel free to install that one. This is the beauty of programming without a framework,
you have total control over your project.
An alternative package would be: [PHP-Error](https://github.com/JosephLenton/PHP-Error)
To install a new package, open up your `composer.json` and add the package to the require part. It should now look
like this:
```php
"require": {
"php": ">=8.1.0",
"filp/whoops": "^2.14"
},
```
Now run `composer update` in your console and it will be installed.
Another way to install packages is to simply type "composer require filp/whoops" into your terminal at the project root,
i that case composer automatically installs the package and updates your composer.json-file.
But you can't use it yet. PHP won't know where to find the files for the classes. For this you will need an autoloader,
ideally a [PSR-4](http://www.php-fig.org/psr/psr-4/) autoloader. Composer already takes care of this for you, so you
only have to add a `require __DIR__ . '/../vendor/autoload.php';` to your `Bootstrap.php`.
**Important:** Never show any errors in your production environment. A stack trace or even just a simple error message
can help someone to gain access to your system. Always show a user friendly error page instead and send an email to
yourself, write to a log or something similar. So only you can see the errors in the production environment.
For development that does not make sense though -- you want a nice error page. The solution is to have an environment
switch in your code. We use the getenv() function here to check the environment and define the 'dev' env as standard in
case no environment has been set.
Then after the error handler registration, throw an `Exception` to test if everything is working correctly.
Your `Bootstrap.php` should now look similar to this:
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework;
use Whoops\Handler\PrettyPageHandler;
use Whoops\Run;
require __DIR__ . '/../vendor/autoload.php';
$environment = getenv('ENVIRONMENT') ?: 'dev';
error_reporting(E_ALL);
$whoops = new Run;
if ($environment == 'dev') {
$whoops->pushHandler(new PrettyPageHandler);
} else {
$whoops->pushHandler(function (\Throwable $e) {
error_log("Error: " . $e->getMessage(), $e->getCode());
echo 'An Error happened';
});
}
$whoops->register();
throw new \Exception("Ooooopsie");
```
You should now see a error page with the line highlighted where you throw the exception. If not, go back and debug until
you get it working. Now would also be a good time for another commit.
[<< previous](02-composer.md) | [next >>](04-development-helpers.md)

View file

@ -0,0 +1,260 @@
[<< previous](03-error-handler.md) | [next >>](05-http.md)
### Development Helpers
I have added some more helpers to my composer.json that help me with development. As these are scripts and programms
used only for development they should not be used in a production environment. Composer has a specific sections in its
file called "dev-dependencies", everything that is required in this section does not get installen in production.
Let's install our dev-helpers and i will explain them one by one:
`composer require --dev phpstan/phpstan php-cs-fixer/shim symfony/var-dumper squizlabs/php_codesniffer`
#### Static Code Analysis with phpstan
Phpstan is a great little tool, that tries to understand your code and checks if you are making any grave mistakes or
create bad defined interfaces and structures. It also helps in finding logic-errors, dead code, access to array elements
that are not (or not always) available, if-statements that always are true and a lot of other stuff.
A very simple example would be a small functions that takes a DateTime-Object and prints it in a human readable format.
```php
/**
* @param \DateTime $date
* @return void
*/
function printDate($date) {
$date->format('Y-m-d H:i:s');
}
printDate('now');
```
if we run phpstan with the command `./vendor/bin/phpstan analyse --level 9 ./src/`
It firstly tells us that calling "format" on a DateTime-Object without outputting or returning the function result has
no use, and secondly, that we are calling the function with a string instead of a datetime object.
```shell
1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
------ ---------------------------------------------------------------------------------------------
Line Bootstrap.php
------ ---------------------------------------------------------------------------------------------
30 Call to method DateTime::format() on a separate line has no effect.
33 Parameter #1 $date of function Lubian\NoFramework\printDate expects DateTime, string given.
------ ---------------------------------------------------------------------------------------------
```
The second error is something that "declare strict-types" already catches for us, but the first error is something that
we usually would not discover easily without speccially looking for this errortype.
We can add a simple configfile called phpstan.neon to our project so that we do not have to specify the errorlevel and
path everytime we want to check our code for errors:
```yaml
parameters:
level: max
paths:
- src
```
now we can just call `./vendor/bin/phpstan analyze` and have the same setting for every developer working in our project
With this settings we have already a great setup to catch some errors before we execute the code, but it still allows us
some silly things, therefore we want to add install some packages that enforce rules that are a little bit more strict.
```shell
composer require --dev phpstan/extension-installer
composer require --dev phpstan/phpstan-strict-rules thecodingmachine/phpstan-strict-rules
```
During the first install you need to allow the extension installer to actually install the extension. The second command
installs some more strict rulesets and activates them in phpstan.
If we now rerun phpstan it already tells us about some errors we have made:
```
------ -----------------------------------------------------------------------------------------------
Line Bootstrap.php
------ -----------------------------------------------------------------------------------------------
10 Short ternary operator is not allowed. Use null coalesce operator if applicable or consider
using long ternary.
25 Do not throw the \Exception base class. Instead, extend the \Exception base class. More info:
http://bit.ly/subtypeexception
26 Unreachable statement - code above always terminates.
------ -----------------------------------------------------------------------------------------------
```
The last two Errors are caused by the Exception we have used to test the ErrorHandler in the last chapter if we remove
that we should be able to fix that. The first error is something we could fix, but I dont want to focus on that specific
problem right now. Phpstan gives us the option to ignore some errors and handle them later. If for example we are working
on an old legacy codebase and wanted to add static analysis to it but cant because we would get 1 Million error messages
everytime we use phpstan, we could add all those errors to a list and tell phpstan to only bother us about new errors we
are adding to our code.
In order to use that we have to add an empty file 'phpstan-baseline.neon' to our project, include that in the
phpstan.neon file and run phpstan with the
'--generate-baseline' option:
```yaml
includes:
- phpstan-baseline.neon
parameters:
level: 9
paths:
- src
```
```shell
[vagrant@archlinux app]$ ./vendor/bin/phpstan analyze --generate-baseline
Note: Using configuration file /home/vagrant/app/phpstan.neon.
1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
[OK] Baseline generated with 1 error.
```
you can read more about the possible parameters and usage options in the [documentation](https://phpstan.org/user-guide/getting-started)
#### PHP-CS-Fixer
Another great tool is the php-cs-fixer, which just applies a specific style to your code.
when you run `./vendor/bin/php-cs-fixer fix ./` it applies the psr-12 code style to every php file in you current
directory.
You can read more about its usage and possible rulesets in the [documentation](https://github.com/FriendsOfPHP/PHP-CS-Fixer#documentation)
personally i like to have a more opiniated version with some rules added to the psr-12 standard and have therefore setup
a configuration file that i use in all my projects .php-cs-fixer.php:
```php
<?php declare(strict_types=1);
/*
* This document has been generated with
* https://mlocati.github.io/php-cs-fixer-configurator/#version:3.1.0|configurator
* you can change this configuration by importing this file.
*/
$config = new PhpCsFixer\Config();
return $config
->setRiskyAllowed(true)
->setRules([
'@PSR12:risky' => true,
'@PSR12' => true,
'@PHP80Migration' => true,
'@PHP80Migration:risky' => true,
'@PHP81Migration' => true,
'array_indentation' => true,
'include' => true,
'blank_line_after_opening_tag' => false,
'native_constant_invocation' => true,
'new_with_braces' => false,
'native_function_invocation' => [
'include' => ['@all']
],
'no_unused_imports' => true,
'global_namespace_import' => [
'import_classes' => true,
'import_constants' => true,
'import_functions' => true,
],
'ordered_interfaces' => true,
])
->setFinder(
PhpCsFixer\Finder::create()
->in([
__DIR__ . '/src',
])
);
```
#### PHP Codesniffer
The PHPCodesniffer is sort of a combination of the previous tools, it checks for a defined codingstyle and some extra
rules that are not just stylechanges but instead enforces extra rules in if-statements, exception handling etc.
it provides the phpcs command to check for violations and the phpcbf command to actually fix most of the violations.
Without configuration the tool tries to apply the PSR12 standard just like the php-cs-fixer, but as you might have
guessed we are adding some extra rules.
Lets install the ruleset with composer
```shell
composer require --dev mnapoli/hard-mode
```
and add a configuration file to actually use it '.phpcs.xml.dist'
```xml
<?xml version="1.0"?>
<ruleset>
<arg name="basepath" value="."/>
<file>src</file>
<rule ref="HardMode"/>
</ruleset>
```
running `./vendor/bin/phpcs` now checks our src directory for violations and gives us a detailed list about the findings.
```
[vagrant@archlinux app]$ ./vendor/bin/phpcs
FILE: src/Bootstrap.php
----------------------------------------------------------------------------------------------------
FOUND 4 ERRORS AFFECTING 4 LINES
----------------------------------------------------------------------------------------------------
7 | ERROR | [x] Use statements should be sorted alphabetically. The first wrong one is Throwable.
8 | ERROR | [x] Expected 1 lines between different types of use statement, found 0.
11 | ERROR | [x] Expected 1 lines between different types of use statement, found 0.
24 | ERROR | [x] String "ERROR: " does not require double quotes; use single quotes instead
----------------------------------------------------------------------------------------------------
PHPCBF CAN FIX THE 4 MARKED SNIFF VIOLATIONS AUTOMATICALLY
----------------------------------------------------------------------------------------------------
Time: 639ms; Memory: 10MB
```
You can then use `./vendor/bin/phpcbf` to try to fix them
#### Symfony Var-Dumper
another great tool for some quick debugging without xdebug is the symfony var-dumper. This just gives us some small
functions.
dump(); is basically like phps var_dump() but has a better looking output that helps when looking into bigger objects
or arrays.
dd() on the other hand is a function that dumps its parameters and then exits the php-script.
you could just write dd($whoops) somewhere in your bootstrap.php to check how the output looks.
#### Composer scripts
now we have a few commands that are available on the command line. i personally do not like to type complex commands
with lots of parameters by hand all the time, so i added a few lines to my composer.json:
```json
"scripts": {
"serve": "php -S 0.0.0.0:1234 -t public",
"phpstan": "./vendor/bin/phpstan analyze",
"baseline": "./vendor/bin/phpstan analyze --generate-baseline",
"check": "./vendor/bin/phpcs",
"fix": "./vendor/bin/php-cs-fixer fix && ./vendor/bin/phpcbf"
},
```
that way i can just type "composer" followed by the command name in the root of my project. if i want to start the
php devserver i can just type "composer serve" and dont have to type in the hostname, port and targetdirectory all the
time.
You could also configure PhpStorm to automatically run these commands in the background and highlight the violations
directly in the file you are currently editing. I personally am not a fan of this approach because it often disrupts my
flow when programming and always forces me to be absolutely strict even if I am only trying out an idea for debugging.
My workflow is to just write my code the way i currently feel and that execute the phpstan and the fix scripts before
commiting and pushing the code.
[<< previous](03-error-handler.md) | [next >>](05-http.md)

View file

@ -0,0 +1,124 @@
[<< previous](04-development-helpers.md) | [next >>](06-router.md)
### HTTP
PHP already has a few things built in to make working with HTTP easier. For example there are the
[superglobals](http://php.net/manual/en/language.variables.superglobals.php) that contain the request information.
These are good if you just want to get a small script up and running, something that won't be hard to maintain. However,
if you want to write clean, maintainable, [SOLID](http://en.wikipedia.org/wiki/SOLID_%28object-oriented_design%29) code,
then you will want a class with a nice object-oriented interface that you can use in your application instead.
Fortunately for us there has been a standard developed in the PHP-Community that is adopted by several Frameworks. The
standard is called [PSR-7](https://www.php-fig.org/psr/psr-7/) and has several interfaces defined that a lot of php
projects implement. This makes it easier for us to use modules developed for other frameworks in our projects.
As this is a widely adopted standard there are already several implementations available for us to use. I will choose
the laminas/laminas-diactoros package as i am an old time fan of the laminas (previously zend) project.
Some alternatives are [slim-psr7](https://github.com/slimphp/Slim-Psr7), [Guzzle](https://github.com/guzzle/psr7) and a
[lot more](https://packagist.org/providers/psr/http-message-implementation) are available for you to choose from.
Symfony ships its own Request and Response objects that do not implement the psr-7 interfaces. Therefore i will not use
that in this tutorial, but if you understand how the psr-7 interfaces work you should have no problem in understanding
the [symfony http-foundation](https://symfony.com/doc/current/components/http_foundation.html#request).
to install the laminas psr-packages just type `composer require laminas/laminas-diactoros` into your console and hit
enter
Now you can add the following below your error handler code in your `Bootstrap.php` (and don't forget to remove the exception):
```php
$request = Laminas\Diactoros\ServerRequestFactory::fromGlobals();
$response = new \Laminas\Diactoros\Response;
$response->getBody()->write('Hello World! ');
$response->getBody()->write('The Uri is: ' . $request->getUri()->getPath());
```
This sets up the `Request` and `Response` objects that you can use in your other classes to get request data and send a response back to the browser.
In order to actually add content to the response you have to access the Body-Streamobject of the Response and use the
write()-Method on that object.
To actually send something back, you will also need to add the following snippet at the end of your `Bootstrap.php` file:
```php
echo $response->getBody();
```
This will send the response data to the browser. If you don't do this, nothing happens as the `Response` object only
stores data.
You can play around with the other methods of the Request object and take a look at its content with the dd() function.
```php
dd($response)
```
Something you have to keep in mind is that the Response and Request objects are Immutable which means that they cannot
be changed after creation. Whenever you want to modify a property you have to call one of the "with" functions, which
creates a copy of the request object with the changed property and returns that clone:
```php
$response = $response->withStatus(200);
$response = $response->withAddedHeader('Content-type', 'application/json');
```
If you have ever struggled with Mutationproblems in an DateTime-Object you might understand why the standard has been
defined this way.
But if you have been keeping attention you might argue that the following line should not work if the request object is
immutable.
```php
$response->getBody()->write('Hello World!');
```
The response-body implements a stream interface which is immutable for some reasons that are described in the
[meta-document](https://www.php-fig.org/psr/psr-7/meta/#why-are-streams-mutable). For me the important thing is to be
aware of the problems that can occur with mutable objects. Here is a small [Blogpost](http://andrew.carterlunn.co.uk/programming/2016/05/22/psr-7-is-not-immutable.html) that gives some context. Beware that the Middleware-Example in
the post is based on a deprecated middleware standard. But more on middlewares will be discussed in later chapters.
I for one am happy about that fact, as it saves me from writing at least 3 lines of code whenever i want to add content
to a response object.
```php
$body = $response->getBody();
$body->write('Hello World!');
$response = $response->withBody($body);
```
Right now we are just outputting the Response-Body without any headers or http-status. So we need to expand our
output-logic a little bit more. Replace the line that echos the response-body with the following:
```php
foreach ($response->getHeaders() as $name => $values) {
$first = strtolower($name) !== 'set-cookie';
foreach ($values as $value) {
$header = sprintf('%s: %s', $name, $value);
header($header, $first);
$first = false;
}
}
$statusLine = sprintf(
'HTTP/%s %s %s',
$response->getProtocolVersion(),
$response->getStatusCode(),
$response->getReasonPhrase()
);
header($statusLine, true, $response->getStatusCode());
echo $response->getBody();
```
This code is still fairly simple and there is a lot more stuff that can be considered when emitting a response to a
webbrowser, if you want a more complete solution you can take a look at the [httpsoft/http-emitter](https://github.com/httpsoft/http-emitter/blob/master/src/SapiEmitter.php) package on github.
Remember that the object is only storing data, so if you set multiple status codes before you send the response, only the last one will be applied.
Be sure to run composer phpstan, composer fix and composer check before moving on to the next chapter
[<< previous](04-development-helpers.md) | [next >>](06-router.md)

View file

@ -0,0 +1,101 @@
[<< previous](05-http.md) | [next >>](07-dispatching-to-a-class.md)
### Router
A router dispatches to different handlers depending on rules that you have set up.
With your current setup it does not matter what URL is used to access the application, it will always result in the same
response. So let's fix that now.
I will use [nikic/fast-route](https://github.com/nikic/FastRoute) in this tutorial. But as always, you can pick your own
favorite package.
Alternative packages: [symfony/Routing](https://github.com/symfony/Routing), [Aura.Router](https://github.com/auraphp/Aura.Router), [fuelphp/routing](https://github.com/fuelphp/routing), [Klein](https://github.com/chriso/klein.php)
By now you know how to install Composer packages, so I will leave that to you.
Now add this code block to your `Bootstrap.php` file where you added the 'hello world' message in the last chapter.
```php
$dispatcher = \FastRoute\simpleDispatcher(function (\FastRoute\RouteCollector $r) {
$r->addRoute('GET', '/hello[/{name}]', function (\Psr\Http\Message\ServerRequestInterface $request) {
$name = $request->getAttribute('name', 'Stranger');
$response = (new \Laminas\Diactoros\Response)->withStatus(200);
$response->getBody()->write('Hello ' . $name . '!');
return $response;
});
$r->addRoute('GET', '/another-route', function (\Psr\Http\Message\ServerRequestInterface $request) {
$response = (new \Laminas\Diactoros\Response)->withStatus(200);
$response->getBody()->write('This works too!');
return $response;
});
});
$routeInfo = $dispatcher->dispatch(
$request->getMethod(),
$request->getUri()->getPath(),
);
switch ($routeInfo[0]) {
case \FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
$response = (new \Laminas\Diactoros\Response)->withStatus(405);
$response->getBody()->write('Method not allowed');
$response = $response->withStatus(405);
break;
case \FastRoute\Dispatcher::FOUND:
$handler = $routeInfo[1];
foreach ($routeInfo[2] as $attributeName => $attributeValue) {
$request = $request->withAttribute($attributeName, $attributeValue);
}
/** @var \Psr\Http\Message\ResponseInterface $response */
$response = call_user_func($handler, $request);
break;
case \FastRoute\Dispatcher::NOT_FOUND:
default:
$response = (new \Laminas\Diactoros\Response)->withStatus(404);
$response->getBody()->write('Not Found!');
break;
}
```
In the first part of the code, you are registering the available routes for your application. In the second part, the
dispatcher gets called and the appropriate part of the switch statement will be executed. If a route was found,
we collect any variable parameters of the route, store them in the request parameterbag and call the handler callable.
If the route dispatcher returns a wrong value in the first entry of the routeMatch array we handle it the same as a 404.
This setup might work for really small applications, but once you start adding a few routes your bootstrap file will
quickly get cluttered. So let's move them out into a separate file.
Create a new directory in you projectroot named 'config' and add a 'routes.php' file with the following content;
```php
<?php declare(strict_types = 1);
return function(\FastRoute\RouteCollector $r) {
$r->addRoute('GET', '/hello[/{name}]', function (\Psr\Http\Message\ServerRequestInterface $request) {
$name = $request->getAttribute('name', 'Stranger');
$response = (new \Laminas\Diactoros\Response)->withStatus(200);
$response->getBody()->write('Hello ' . $name . '!');
return $response;
});
$r->addRoute('GET', '/another-route', function (\Psr\Http\Message\ServerRequestInterface $request) {
$response = (new Laminas\Diactoros\Response)->withStatus(200);
$response->getBody()->write('This works too!');
return $response;
});
};
```
Now let's rewrite the route dispatcher part to use the `Routes.php` file.
```php
$routeDefinitionCallback = require __DIR__ . '/../config/routes.php';
$dispatcher = \FastRoute\simpleDispatcher($routeDefinitionCallback);
```
This is already an improvement, but now all the handler code is in the `routes.php` file. This is not optimal, so let's fix that in the next part.
Of course we now need to add the 'config' folder to the configuration files of our
devhelpers so that they can scan that directory as well.
[<< previous](05-http.md) | [next >>](07-dispatching-to-a-class.md)

View file

@ -0,0 +1,137 @@
[<< previous](06-router.md) | [next >>](08-inversion-of-control.md)
### Dispatching to a Class
In this tutorial we won't implement [MVC (Model-View-Controller)](http://martinfowler.com/eaaCatalog/modelViewController.html).
MVC can't be implemented properly in PHP anyway, at least not in the way it was originally conceived. If you want to
learn more about this, read [A Beginner's Guide To MVC](http://blog.ircmaxell.com/2014/11/a-beginners-guide-to-mvc-for-web.html)
and the followup posts.
So forget about MVC and instead let's worry about [separation of concerns](http://en.wikipedia.org/wiki/Separation_of_concerns).
We will need a descriptive name for the classes that handle the requests. For this tutorial I will use `Handler`, other
common names are 'Controllers' or 'Actions'.
Create a new folder inside the `src/` folder with the name `Handler`.In this folder we will place all our action classes.
In there, create a `Hello.php` file.
```php
<?php declare(strict_types = 1);
namespace Lubian\NoFramework\Action;
final class Hello implements \Psr\Http\Server\RequestHandlerInterface
{
public function handle(\Psr\Http\Message\ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface
{
$name = $request->getAttribute('name', 'Stranger');
$response = (new \Laminas\Diactoros\Response)->withStatus(200);
$response->getBody()->write('Hello ' . $name . '!');
return $response;
}
}
```
You can see that we implement the [RequestHandlerInterface](https://github.com/php-fig/http-server-handler/blob/master/src/RequestHandlerInterface.php)
that has a 'handle'-Method with requires a Requestobject as its parameter and returns a Responseobject. For now this is
fine, but we may have to change our approach later. In anyway it is good to know about this interface as we will implement
it in some other parts of our application as well. In order to use that Interface we have to require it with composer:
'composer require psr/http-server-handler'.
The autoloader will only work if the namespace of a class matches the file path and the file name equals the class name.
At the beginning I defined `Lubian\NoFramework` as the root namespace of the application so this is referring to the `src/` folder.
Now let's change the hello world route so that it calls your new class method instead of the closure. Change your `routes.php` to this:
```php
return function(\FastRoute\RouteCollector $r) {
$r->addRoute('GET', '/hello[/{name}]', \Lubian\NoFramework\Action\Hello::class);
$r->addRoute('GET', '/another-route', \Lubian\NoFramework\Action\Another::class);
};
```
Instead of a callable we are now passing the fully namespaced class identifier to the route-definition. I also declared
the class 'Another' as the target for the second route, you can create it by copying the Hello.php file and changing
the response to the one we defined for the second route.
To make this work, you will also have to do a small refactor to the routing part of the `Bootstrap.php`:
```php
case \FastRoute\Dispatcher::FOUND:
$handler = new $routeInfo[1];
if (! $handler instanceof \Psr\Http\Server\RequestHandlerInterface) {
throw new \Exception('Invalid Requesthandler');
}
foreach ($routeInfo[2] as $attributeName => $attributeValue) {
$request = $request->withAttribute($attributeName, $attributeValue);
}
$response = $handler->handle($request);
assert($response instanceof \Psr\Http\Message\ResponseInterface)
break;
```
So instead of just calling a method you are now instantiating an object and then calling the method on it.
Now if you visit `http://localhost:1234/` everything should work. If not, go back and debug.
And of course don't forget to commit your changes.
Something that still bothers me is the fact, that we do have classes for our Handlers, but the Error responses are still
generated in the routing-matching section and not in special classes. Also we have still left some cases to chance, for
example if there is an error in creating our RequestHandler class or if the call to the 'handle' function fails. We still
have our whoopsie error-handler but i like to be more explicit in my control flow.
In order to do that we need to define some special Exceptions that we can throw and catch explicitly. Lets add a new
Folder/Namespace to our src directory called Exceptions. And define the classes NotFound, MethodNotAllowed and
InternalServerError. All three should extend phps Base Exception class.
Here is my NotFound.php for example.
```php
<?php
declare(strict_types=1);
namespace Lubian\NoFramework\Exception;
final class NotFound extends Exception{}
```
Use that example to create a MethodNotAllowedException.php and InternalServerErrorException.php as well.
After you have created those we update our Routercode to use the new Exceptions.
```php
try {
switch ($routeInfo[0]) {
case Dispatcher::FOUND:
$className = $routeInfo[1];
$handler = new $className;
assert($handler instanceof RequestHandlerInterface);
foreach ($routeInfo[2] as $attributeName => $attributeValue) {
$request = $request->withAttribute($attributeName, $attributeValue);
}
$response = $handler->handle($request);
break;
case Dispatcher::METHOD_NOT_ALLOWED:
throw new MethodNotAllowed;
case Dispatcher::NOT_FOUND:
default:
throw new NotFound;
}
} catch (MethodNotAllowed) {
$response = (new Response)->withStatus(405);
$response->getBody()->write('Not Allowed');
} catch (NotFound) {
$response = (new Response)->withStatus(404);
$response->getBody()->write('Not Found');
} catch (Throwable $t) {
throw new InternalServerError($t->getMessage(), $t->getCode(), $t);
}
```
Check if our code still works, try to trigger some errors, run phpstan and the fix command
and don't forget to commit your changes.
[<< previous](06-router.md) | [next >>](08-inversion-of-control.md)

View file

@ -0,0 +1,54 @@
[<< previous](07-dispatching-to-a-class.md) | [next >>](09-dependency-injector.md)
### Inversion of Control
In the last part you have set up a controller class and generated our Http-Response-object in that class, but if we
want to switch to a more powerfull Http-Implementation later, or need to create our own for some special purposes, then
we would need to edit every one of our request handlers to call a different constructor of the class.
The sane option is to use [inversion of control](http://en.wikipedia.org/wiki/Inversion_of_control). This means that
instead of giving the class the responsiblity of creating the object it needs, you just ask for them. This is done
with [dependency injection](http://en.wikipedia.org/wiki/Dependency_injection).
If this sounds a little complicated right now, don't worry. Just follow the tutorial and once you see how it is
implemented, it will make sense.
Change your `Hello` action to the following:
```php
<?php declare(strict_types = 1);
namespace Lubian\NoFramework\Action;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
final class Hello implements \Psr\Http\Server\RequestHandlerInterface;
{
public function __construct(
private Response $response
) {}
public function handle(Request $request): Response
{
$name = $request->getAttribute('name', 'Stranger');
$body = $this->response->getBody();
$body->write('Hello ' . $name . '!');
return $this->response
->withBody($body)
->withStatus(200);
}
}
```
Now the code will result in an error because we are not actually injecting anything. So let's fix that in the `Bootstrap.php` where we dispatch when a route was found:
```php
$handler = new $className($response);
```
Of course we need to also update all the other handlers.
[<< previous](07-dispatching-to-a-class.md) | [next >>](09-dependency-injector.md)

View file

@ -0,0 +1,213 @@
[<< previous](08-inversion-of-control.md) | [next >>](10-invoker.md)
### Dependency Injector
A dependency injector resolves the dependencies of your class and makes sure that the correct objects are injected when
the class is instantiated.
Again the psr has defined an [interface](https://www.php-fig.org/psr/psr-11/) for dependency injection that we can work
with. Almost all common dependency injection containers implement this interface, so it is a good starting point to look
for a [suitable solution on packagist](https://packagist.org/providers/psr/container-implementation).
I choose the [PHP-DI](https://packagist.org/packages/php-di/php-di) container, as it is easy to configure and provides some very [powerfull features](https://php-di.org/#autowiring)
out of the box.
After installing the container through composer create a new file with the name 'dependencies.php' in your config folder:
```php
<?php declare(strict_types = 1);
$builder = new \DI\ContainerBuilder();
$builder->addDefinitions([
\Psr\Http\Message\ResponseInterface::class => \DI\create(\Laminas\Diactoros\Response::class),
\Psr\Http\Message\ServerRequestInterface::class => fn () => \Laminas\Diactoros\ServerRequestFactory::fromGlobals(),
]);
return $builder->build();
```
In this file we create a containerbuilder, add some definitions to it and return the container.
As the container supports autowiring we only need to define services where we want to use a specific implementation of
an interface.
In the example i used two different ways of defining the service. The first is by using the 'create' method of PHP-DI to
tell the container that it should create a Diactoros\Response object when ever I query a ResponseInterface, in the second
exampler I choose to write a small factory closure that wraps the Laminas Requestfactory.
Make sure to read the documentation on definition types on the [PHP-DI website](https://php-di.org/doc/php-definitions.html#definition-types),
as we will use that extensively.
Of course your `Bootstrap.php` will also need to be changed. Before you were setting up `$request` and `$response` with `new` calls. Switch that to the dependency container. We do not need to get the response here, as the container will create and use it internally
to create our Handler-Object
```php
$container = require __DIR__ . '/../config/dependencies.php';
assert($container instanceof \Psr\Container\ContainerInterface);
$request = $container->get(\Psr\Http\Message\ServerRequestInterface::class);
assert($request instanceof \Psr\Http\Message\ServerRequestInterface);
```
The other part that has to be changed is the dispatching of the route. Before you had the following code:
```php
$className = $routeInfo[1];
$handler = new $className($response);
assert($handler instanceof \Psr\Http\Server\RequestHandlerInterface)
foreach ($routeInfo[2] as $attributeName => $attributeValue) {
$request = $request->withAttribute($attributeName, $attributeValue);
}
$response = $handler->handle($request);
```
Change that to the following:
```php
/** @var RequestHandlerInterface $handler */
$className = $routeInfo[1];
$handler = $container->get($className);
assert($handler instanceof RequestHandlerInterface);
foreach ($routeInfo[2] as $attributeName => $attributeValue) {
$request = $request->withAttribute($attributeName, $attributeValue);
}
$response = $handler->handle($request);
```
Make sure to use the container fetch the response object in the catch blocks as well:
```php
} catch (MethodNotAllowed) {
$response = $container->get(ResponseInterface::class);
assert($response instanceof ResponseInterface);
$response = $response->withStatus(405);
$response->getBody()->write('Not Allowed');
} catch (NotFound) {
$response = $container->get(ResponseInterface::class);
assert($response instanceof ResponseInterface);
$response = $response->withStatus(404);
$response->getBody()->write('Not Found');
}
```
Now all your controller constructor dependencies will be automatically resolved with PHP-DI.
We can now use that to inject all kinds of services. Often we need to work with the Current time to do some comparisons
in an application. Of course we are writing S.O.L.I.D. and testable code so that we would never be so crazy as to call
`$time = new \DateTimeImmutable();` in our Action directly, because then we would need to change the system time of we
want to work with a different date in a test.
Therefore we are creating a new Namespace called 'Service\Time' where we introduce a Now-Interface and an Implementation
that creates us a DateTimeImmutable object with the current date and time.
src/Service/Time/Now.php:
```php
namespace Lubian\NoFramework\Service\Time;
interface Now
{
public function __invoke(): \DateTimeImmutable;
}
```
src/Service/Time/SystemClockNow.php:
```php
namespace Lubian\NoFramework\Service\Time;
final class SystemClockNow implements Now
{
public function __invoke(): \DateTimeImmutable
{
return new \DateTimeImmutable;
}
}
```
If we want to use that Service in our HelloAction we just need to add it as another argument for the Constructor and
update the handle-method to use the new class property:
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Action;
use Lubian\NoFramework\Service\Time\SystemClockNow;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class Hello implements RequestHandlerInterface
{
public function __construct(
private ResponseInterface $response,
private SystemClockNow $now,
)
{
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$name = $request->getAttribute('name', 'Stranger');
$nowAsString = ($this->now)()->format('H:i:s');
$body = $this->response->getBody();
$body->write('Hello ' . $name . '!');
$body->write(' The Time is ' . $nowAsString);
return $this->response
->withBody($body)
->withStatus(200);
}
}
```
If you open the route in your browser you should see that the current time gets displayed. This happens because PHP-DI
automatically figures out what classes are requested in the constructor and tries to create the objects needed.
But we do not want to depend on the SystemClockNow implementation in our class because that would violate our sacred
S.O.L.I.D. principles therefore we need to change the Typehint to the Now interface:
```php
public function __construct(
private ResponseInterface $response,
private Now $now,
)
```
When we are now accessing the Handler in the Browser we get an Error because we have not defined which implementation
should be use to satisfy dependencies on the Now interface. So lets add that definition to our dependencies file:
```php
\Lubian\NoFramework\Service\Time\Now::class => fn () => new \Lubian\NoFramework\Service\Time\SystemClockNow(),
```
we could also use the PHP-DI create method to delegate the object creation to the container implementation:
```php
\Lubian\NoFramework\Service\Time\Now::class => DI\create(\Lubian\NoFramework\Service\Time\SystemClockNow::class),
```
this way the container can try to resolve any dependencies that the class might have internally, but prefer the other
method because we are not depending on this specific dependency injection implementation.
Either way the container should now be able to correctly resolve the dependency on the Now interfacen when you are
requesting the Hello action.
If you run phpstan now, you will get some errors, because the get method on the ContainerInterface returns 'mixed'. As
we will adress these issues later, lets tell phpstan that we know about the issue and we can ignore it for now. This way
we wont get any warnings for this particular issue, but for any other issues we add to our code.
Update the phpstan.neon file to include a "baseline" file:
```
includes:
- phpstan-baseline.neon
parameters:
level: 9
paths:
- src
```
if we run phpstan with './vendor/bin/phpstan analyse --generate-baseline' it will add all current errors to that file and
ignore them in the future. You can also add that command to your composer.json for easier access. I have called it just
'baseline'
[<< previous](08-inversion-of-control.md) | [next >>](10-invoker.md)

View file

@ -0,0 +1,102 @@
[<< previous](09-dependency-injector.md) | [next >>](11-templating.md)
### Invoker
Currently all our Actions need to implement the RequestHandlerInterface, which forces us to accept the Request as the
one and only argument to our handle function, but most of the time we only need a few attributes in our Action a long
with some services and not the whole Requestobject with all its various properties.
If we take our Hello action for example we only need a response object, the time service and the 'name' information from
the request-uri. And as that class only provides one simple method we could easily make that invokable as we alreay named
the class hello and it would be redundant to also call the the method hello. So an updated version of that class could
look like this:
```php
final class Hello
{
public function __invoke(
ResponseInterface $response,
Now $now,
string $name = 'Stranger',
): ResponseInterface
{
$body = $this->response->getBody();
$nowString = $now->get()->format('H:i:s');
$body->write('Hello ' . $name . '!');
$body->write(' The Time is ' . $nowString);
return $response
->withBody($body)
->withStatus(200);
}
}
```
It would also be neat if we could define a classname plus a method as target handler in our routes, or even a short
closure function if we want to redirect all requests from '/' to '/hello' because we have not defined a handler for the
rootpath of our application yet.
```php
$r->addRoute('GET', '/hello[/{name}]', Hello::class);
$r->addRoute('GET', '/other-route', [Other::class, 'someFunctionName']);
$r->addRoute('GET', '/', fn (Response $r) => $r->withStatus(302)->withHeader('Location', '/hello'));
```
In order to support this crazy route definitions we would need to write a lot of for actually calling the result of the
route dispatcher. If the result is a name of an invokable class we would use the container to create an instance of that
class for us and then use the [reflection api](https://www.php.net/manual/en/book.reflection.php) to figure out what
arguments the __invoke function has, try to fetch all arguments from the container and then add some more from the router
if they are needed and available. The same if we have an array of a class name with a function to call, and for a simple
callable we would need to manually use reflection as well to resolve all the arguments.
But we are quite lucky as the PHP-DI container provides us with a [great 'call' method](https://php-di.org/doc/container.html#call)
which handles all of that for us.
After you added the described changes to your routes file you can modify the Dispatcher::FOUND case of you $routeInfo
switch section in the Bootstrap.php file to use the container->call() method:
```php
$handler = $routeInfo[1];
$args = $routeInfo[2];
foreach ($routeInfo[2] as $attributeName => $attributeValue) {
$request = $request->withAttribute($attributeName, $attributeValue);
}
$args['request'] = $request;
$response = $container->call($handler, $args);
```
Try to open [localhost:1234/](http://localhost:1234/) in your browser and check if you are getting redirected to '/hello'.
But by now you should know that I do not like to depend on specific implementations and the call method is not defined in
the psr/container interface. Therefore we would not be able to use that if we are ever switching to the symfony container
or any other implementation.
Fortunately for us (or me) the PHP-CI container ships that function as its own class that is independent of the specific
container implementation so we could use it with any container that implements the ContainerInterface. And best of all
the class ships with its own [Interface](https://github.com/PHP-DI/Invoker/blob/master/src/InvokerInterface.php) that
we could implement if we ever want to write our own implementation or we could write an adapter that uses a different
class that solves the same problem.
But for now we are using the solution provided by PHP-DI.
So lets request a Service implementing the InvokerInterface from the container and use that inside of the switch-case block
```php
$handler = $routeInfo[1];
$args = $routeInfo[2] ?? [];
foreach ($routeInfo[2] as $attributeName => $attributeValue) {
$request = $request->withAttribute($attributeName, $attributeValue);
}
$args['request'] = $request;
$invoker = $container->get(InvokerInterface::class);
assert($invoker instanceof InvokerInterface);
$response = $invoker->call($handler, $args);
assert($response instanceof ResponseInterface);
```
Now we are able to define absolutely everything in routes that is considered a [callable](https://www.php.net/manual/de/language.types.callable.php)
by php, and even some more.
But let us move on to something more fun and add some templating functionality to our application as we are trying to build
a website in the end.
[<< previous](09-dependency-injector.md) | [next >>](11-templating.md)

View file

@ -0,0 +1,240 @@
[<< previous](10-invoker.md) | [next >>](12-configuration.md)
### Templating
A template engine is not necessary with PHP because the language itself can take care of that. But it can make things
like escaping values easier. They also make it easier to draw a clear line between your application logic and the
template files which should only put your variables into the HTML code.
A good quick read on this is [ircmaxell on templating](http://blog.ircmaxell.com/2012/12/on-templating.html). Please
also read [this](http://chadminick.com/articles/simple-php-template-engine.html) for a different opinion on the topic.
Personally I don't have a strong opinion on the topic, so decide yourself which approach works better for you.
For this tutorial we will use a PHP implementation of [Mustache](https://github.com/bobthecow/mustache.php). So install
that package before you continue (`composer require mustache/mustache`).
Another well known alternative would be [Twig](http://twig.sensiolabs.org/).
Now please go and have a look at the source code of the
[engine class](https://github.com/bobthecow/mustache.php/blob/master/src/Mustache/Engine.php). As you can see, the class
does not implement an interface.
You could just type hint against the concrete class. But the problem with this approach is that you create tight
coupling.
In other words, all your code that uses the engine will be coupled to this mustache package. If you want to change the
implementation you have a problem. Maybe you want to switch to Twig, maybe you want to write your own class or you want
to add functionality to the engine. You can't do that without going back and changing all your code that is tightly
coupled.
What we want is loose coupling. We will type hint against an interface and not a class/implementation. So if you need
another implementation, you just implement that interface in your new class and inject the new class instead.
Instead of editing the code of the package we will use the [adapter pattern](http://en.wikipedia.org/wiki/Adapter_pattern).
This sounds a lot more complicated than it is, so just follow along.
First let's define the interface that we want. Remember the [interface segregation principle](http://en.wikipedia.org/wiki/Interface_segregation_principle).
This means that instead of large interfaces with a lot of methods we want to make each interface as small as possible.
A class can implement multiple interfaces if necessary.
So what does our template engine actually need to do? For now we really just need a simple `render` method. Create a
new folder in your `src/` folder with the name `Template` where you can put all the template related things.
In there create a new interface `Renderer.php` that looks like this:
```php
<?php declare(strict_types = 1);
namespace Lubian\NoFramework\Template;
interface Renderer
{
/**
* @param string $template
* @param array<string, mixed> $data
* @return string
*/
public function render(string $template, array $data = []) : string;
}
```
Now that this is sorted out, let's create the implementation for mustache. In the same folder, create the file
`MustacheRenderer.php` with the following content:
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Template;
final class MustacheRenderer implements Renderer
{
public function __construct(private \Mustache_Engine $engine){}
public function render(string $template, array $data = []): string
{
return $this->engine->render($template, $data);
}
}
```
As you can see the adapter is really simple. While the original class had a lot of methods, our adapter is really simple
and only fulfills the interface.
Of course we also have to add a definition in our `dependencies.php` file because otherwise the container won't know
which implementation he has to inject when you hint for the interface. Add this line:
```php
[
...
\Lubian\NoFramework\Template\Renderer::class => DI\create(\Lubian\NoFramework\Template\MustacheRenderer::class)
->constructor(new Mustache_Engine),
]
```
Now update the Hello.php class to require an implementation of our renderer interface
and use that to render a string using mustache syntax.
```php
final class Hello
{
public function __invoke(
ResponseInterface $response,
Now $now,
Renderer $renderer,
string $name = 'Stranger',
): ResponseInterface {
$body = $response->getBody();
$data = [
'now' => $now()->format('H:i:s'),
'name' => $name,
];
$content = $renderer->render(
'Hello {{name}}, the time is {{now}}!',
$data,
);
$body->write($content);
return $response
->withStatus(200)
->withBody($body);
}
}
```
Now go check quickly in your browser if everything works. By default Mustache uses a simple string handler.
But what we want is template files, so let's go back and change that.
To make this change we need to pass an options array to the `Mustache_Engine` constructor. So let's go back to the
`dependencies.php` file and add the following code:
```php
[
...
Mustache_Loader_FilesystemLoader::class => fn() => new Mustache_Loader_FilesystemLoader(__DIR__ . '/../templates', ['extension' => '.html']),
Mustache_Engine::class => fn (Mustache_Loader_FilesystemLoader $MLFsl) => new Mustache_Engine(['loader' => $MLFsl]),
]
```
We are passing an options array because we want to use the `.html` extension instead of the default `.mustache` extension.
Why? Other template languages use a similar syntax and if we ever decide to change to something else then we won't have
to rename all the template files.
To let PHP-DI use its magic for creating our MustacheRenderer class we need to tell it exactly how to wire all the
dependencies, therefore I defined how to create the Filesystemloader, on the next line we typehinted that loader
in the short closure which acts as a factory method for the Mustache_Engine, as PHP-DI automatically injects the Object
we can then use it in the factory.
In your project root folder, create a `templates` folder. In there, create a file `hello.html`. The content of the file should look like this:
```
<h1>Hello World</h1>
Hello {{ name }}
```
Now you can go back to your `Hello` action and change the render line to `$html = $this->renderer->render('hello', $data);`
Navigate to the hello page in your browser to make sure everything works.
One thing that still bothers me is the fact that we have some configuration paths scattered in our dependencies
file. We could add a simple valueobject to our code that gives us a typesafe access to our configuration
values.
Lets create a 'Settings' class in our './src' Folder:
```php
<?php
declare(strict_types=1);
namespace Lubian\NoFramework;
final class Settings
{
public function __construct(
public readonly string $environment,
public readonly string $templateDir,
public readonly string $templateExtension,
){}
}
```
I am using a new Feature from PHP 8.1 here called [readonly properties](https://stitcher.io/blog/php-81-readonly-properties) to write a small valueobject without the need to write complex getters and setters. The linked article gives a great explanation on how they work.
When creating an instance of the setting class with my project specific values i will use another
new feature called [named arguments](https://stitcher.io/blog/php-8-named-arguments). There is
a lot of discussion on the topic of named arguments as some argue it creates unclean and
unmaintainable code, but vor simple valueobjects i would argue that they are ok.
here is a small example of creating a settings object using named arguments.
```php
$setting = new Settings(
environment: getenv('ENVIRONMENT') ?: 'dev',
templateDir: getenv('TEMPLATE_DIR') ?: __DIR__ . '/../templates',
templateExtension: getenv('TEMPLATE_EXTENSION') ?: '.html',
);
```
lets put that code in a file called settings.php in our config folder, and return the settings object
from there
Here is my updated dependencies.php with some imports and aliases added for my convenience:
```php
<?php
declare(strict_types=1);
use DI\ContainerBuilder;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;
use Lubian\NoFramework\Settings;
use Lubian\NoFramework\Template\Mustache;
use Lubian\NoFramework\Template\Renderer;
use Mustache_Engine as ME;
use Mustache_Loader_FilesystemLoader as MLFsl;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use function DI\create;
$builder = new ContainerBuilder();
$builder->addDefinitions([
Settings::class => fn () => require __DIR__ '/settings.php',
ResponseInterface::class => create(Response::class),
ServerRequestInterface::class => fn () => ServerRequestFactory::fromGlobals(),
Renderer::class => fn (ME $me) => new Mustache($me),
MLFsl::class => fn (Settings $s) => new MLFsl($s->templateDir, ['extension' => $s->templateExtension]),
ME::class => fn (MLFsl $MLFsl) => new ME(['loader' => $MLFsl]),
]);
return $builder->build();
```
And as always, don't forget to commit your changes.
[<< previous](10-invoker.md) | [next >>](12-configuration.md)

View file

@ -0,0 +1,201 @@
[<< previous](11-templating.md) | [next >>](13-refactoring.md)
### Configuration
In the last chapter we added some more definitions to our dependencies.php in that definitions
we needed to pass quite a few configuration settings and filesystem strings to the constructors
of the classes. This might work for a small projects, but if we are growing we want to source that out to a more explicit file that holds all the configuration valuse for our project.
As this is not a problem unique to our project there are already a some options available. Some projects use [.env](https://github.com/vlucas/phpdotenv) files, others use [.ini](https://www.php.net/manual/de/function.parse-ini-file.php), there is [yaml](https://www.php.net/manual/de/function.yaml-parse-file.php) as well some frameworks have implemented complex Readers for many configuration file formats that can be used, take a look at the [laminas config component](https://docs.laminas.dev/laminas-config/reader/) for example.
As i am a big fan of writing everything in php, which gives our IDE the chance to autocomplete our code better I am quite happy the PHP8 gives us some tools to achieve easy to use configuration via php. You can take a look at [this blogpost](https://stitcher.io/blog/what-about-config-builders) to read about some considerations on that topic before moving on.
Lets create a 'Settings' class in our './src' Folder:
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework;
final class Settings
{
public function __construct(
public readonly string $environment,
public readonly string $dependenciesFile,
public readonly string $templateDir,
public readonly string $templateExtension,
) {
}
}
```
I am using a new Feature from PHP 8.1 here called [readonly properties](https://stitcher.io/blog/php-81-readonly-properties) to write a small valueobject without the need to write complex getters and setters. The linked article gives a great explanation on how they work.
When creating an instance of the setting class with my project specific values i will use another
new feature called [named arguments](https://stitcher.io/blog/php-8-named-arguments). There is
a lot of discussion on the topic of named arguments as some argue it creates unclean and
unmaintainable code, but vor simple valueobjects i would argue that they are ok.
here is a small example of creating a settings object using named arguments that I placed in the config folder
under the name settings.php
```php
<?php declare(strict_types=1);
use Lubian\NoFramework\Settings;
return new Settings(
environment: 'dev',
dependenciesFile: __DIR__ . '/dependencies.php',
templateDir: __DIR__ . '/../templates',
templateExtension: '.html',
);
```
But now we need some more code to include that settings Object and make it available in our container. As I don't want to use requires and includes too much in the dependencies configuration we are going to create a Factory class that gives us an Instance of the config file.
Lets define our Interface first. That way we can later switch to another implementation that creates our Settings object.
src/Factory/SettingsProvider.php:
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Lubian\NoFramework\Settings;
interface SettingsProvider
{
public function getSettings(): Settings;
}
```
And write a simple implementation that uses our settings.php to provide our App with the Settingsobject:
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Lubian\NoFramework\Settings;
final class FileSystemSettingsProvider implements SettingsProvider
{
public function __construct(
private string $filePath
) {
}
public function getSettings(): Settings
{
return require $this->filePath;
}
}
```
If we later want to use yaml or ini files for our Settings we can easily write a different provider to read those files
and craft a settings object from them.
As we have now created a completely new Namespace and Folder and our SettingsProvider is all alone we could add another
factory for our Container because everyone should have a Friend :)
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Psr\Container\ContainerInterface;
interface ContainerProvider
{
public function getContainer(): ContainerInterface;
}
```
And a simple implementation that uses our new Settingsprovider to build the container:
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use DI\ContainerBuilder;
use Lubian\NoFramework\Settings;
use Psr\Container\ContainerInterface;
final class SettingsContainerProvider implements ContainerProvider
{
public function __construct(
private SettingsProvider $settingsProvider,
) {
}
public function getContainer(): ContainerInterface
{
$builder = new ContainerBuilder;
$settings = $this->settingsProvider->getSettings();
$dependencies = require $settings->dependenciesFile;
$dependencies[Settings::class] = fn () => $settings;
$builder->addDefinitions($dependencies);
return $builder->build();
}
}
```
For this to work we need to change our dependencies.php file to just return the array of definitions:
And here we can instantly use the Settings object to create our template engine.
```php
<?php declare(strict_types=1);
use Laminas\Diactoros\ResponseFactory;
use Laminas\Diactoros\ServerRequestFactory;
use Lubian\NoFramework\Service\Time\Now;
use Lubian\NoFramework\Service\Time\SystemClockNow;
use Lubian\NoFramework\Settings;
use Lubian\NoFramework\Template\MustacheRenderer;
use Lubian\NoFramework\Template\Renderer;
use Mustache_Engine as ME;
use Mustache_Loader_FilesystemLoader as MLF;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
return [
ResponseInterface::class => fn (ResponseFactory $rf) => $rf->createResponse(),
ServerRequestInterface::class => fn (ServerRequestFactory $rf) => $rf::fromGlobals(),
Now::class => fn (SystemClockNow $n) => $n,
Renderer::class => fn (Mustache_Engine $e) => new MustacheRenderer($e),
MLF::class => fn (Settings $s) => new MLF($s->templateDir, ['extension' => $s->templateExtension]),
ME::class => fn (MLF $mfl) => new ME(['loader' => $mfl]),
];
```
Now we can change our Bootstrap.php file to use the new Factories for the creation of the Initial Objects:
require __DIR__ . '/../vendor/autoload.php';
```php
...
error_reporting(E_ALL);
$settingsProvider = new FileSystemSettingsProvider(__DIR__ . '/../config/settings.php');
$container = (new SettingsContainerProvider($settingsProvider))->getContainer();
$settings = $settingsProvider->getSettings();
$whoops = new Run;
if ($settings->environment === 'dev') {
$whoops->pushHandler(new PrettyPageHandler);
} else {
$whoops->pushHandler(function (Throwable $e): void {
error_log('Error: ' . $e->getMessage(), $e->getCode());
echo 'An Error happened';
});
}
$whoops->register();
...
```
Check if everything still works, run your code quality checks and commit the changes before moving on the the next chapter.
[<< previous](11-templating.md) | [next >>](13-refactoring.md)

View file

@ -0,0 +1,377 @@
[<< previous](12-configuration.md) | [next >>](14-middleware.md)
### Refactoring
By now our Bootstrap.php file has grown quite a bit, and with the addition of our dependency container there is now no
reason not to introduce a lot of classes and interfaces for all the that are happening in the bootstrap file.
After all the bootstrap file should just set up the classes needed for the handling logic and execute them.
At the bottom of our Bootstrap.php we have our Response-Emitter Logic, lets create an Interface and a class for that.
As I am really lazy I just selected the code in PhpStorm, klicken on 'Refactor -> extract method' then selected the
method and clicked on 'Refactor -> extract class'. I choose 'BasicEmitter' for the classname, changed the method to non
static and extracted an interface.
'./src/Http/Emitter.php'
```php
<?php
namespace Lubian\NoFramework\Service\Http;
use Psr\Http\Message\ResponseInterface;
interface Emitter
{
public function emit(ResponseInterface $response, bool $withoutBody = false): void;
}
```
'./src/Http/BasicEmitter.php'
```php
<?php
declare(strict_types=1);
namespace Lubian\NoFramework\Service\Http;
use Psr\Http\Message\ResponseInterface;
final class BasicEmitter implements Emitter
{
public function emit(ResponseInterface $response, bool $withoutBody = false): void
{
/** @var string $name */
foreach ($response->getHeaders() as $name => $values) {
$first = strtolower($name) !== 'set-cookie';
foreach ($values as $value) {
$header = sprintf('%s: %s', $name, $value);
header($header, $first);
$first = false;
}
}
$statusLine = sprintf(
'HTTP/%s %s %s',
$response->getProtocolVersion(),
$response->getStatusCode(),
$response->getReasonPhrase()
);
header($statusLine, true, $response->getStatusCode());
if ($withoutBody) {
return;
}
echo $response->getBody();
}
}
```
After registering the BasicEmitter to implement the Emitter interface in the dependencies file you can use the following
code in the Bootstrap.php to emit the response:
```php
/** @var Emitter $emitter */
$emitter = $container->get(Emitter::class);
$emitter->emit($response);
```
If at some point you need a [more advanced emitter](https://github.com/httpsoft/http-emitter), you could now easily
write an adapter that implements your emitter interface and wraps that more advanced emitter
Now that we have our Emitter in a seperate class we need to take care of the big block that handles our routing and
calling the routerhandler that in the passes the request to a function and gets the response.
For this to steps to be seperated we are going to create two more classes:
1. a RouteDecorator, that finds the correct handler for the requests and adds its findings to the Request Object
2. A Requesthandler that implements the RequestHandlerInterface, gets the information for the request handler from the
requestobject, fetches the correct object from the container and calls it to create a response.
Lets create the HandlerInterface first:
```php
<?php
declare(strict_types=1);
namespace Lubian\NoFramework\Service\Http;
use Psr\Http\Server\RequestHandlerInterface;
interface RoutedRequestHandler extends RequestHandlerInterface
{
/**
* sets the Name of the ServerRequest attribute where the route
* information should be stored.
*/
public function setRouteAttributeName(string $routeAttributeName = '__route_handler'): void;
}
```
By looking at the namespace and interfacename you should be able to figure out where to place the file and how to name
it.
We define a public function that the router can use to tell the handler which attribute name to look for in the request.
Now write an implementation that uses a container to satisfy the interface.
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Http;
use Invoker\InvokerInterface;
use Lubian\NoFramework\Exception\InternalServerError;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use function assert;
final class InvokerRoutedHandler implements RoutedRequestHandler
{
public function __construct(
private readonly InvokerInterface $invoker,
private string $routeAttributeName = '__route_handler',
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$handler = $request->getAttribute($this->routeAttributeName, false);
assert($handler !== false);
$vars = $request->getAttributes();
$vars['request'] = $request;
$response = $this->invoker->call($handler, $vars);
if (! $response instanceof ResponseInterface) {
throw new InternalServerError('Handler returned invalid response');
}
return $response;
}
public function setRouteAttributeName(string $routeAttributeName = '__route_handler'): void
{
$this->routeAttributeName = $routeAttributeName;
}
}
```
We will define our routing class to implement the MiddlewareInterface, you can install that with 'composer require psr/http-server-middleware'.
The interface requires us to implement a method called 'process' a Request as its first argument and an RequestHandler
as the second one. The return value of the method needs to be a Responseobject. We will learn more about Middlewares in
the next chapter.
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Http;
use FastRoute\Dispatcher;
use Lubian\NoFramework\Exception\InternalServerError;
use Lubian\NoFramework\Exception\MethodNotAllowed;
use Lubian\NoFramework\Exception\NotFound;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Throwable;
final class RouteMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly Dispatcher $dispatcher,
private readonly ResponseFactoryInterface $responseFactory,
private readonly string $routeAttributeName = '__route_handler',
) {
}
private function decorateRequest(
ServerRequestInterface $request,
): ServerRequestInterface {
$routeInfo = $this->dispatcher->dispatch(
$request->getMethod(),
$request->getUri()->getPath(),
);
if ($routeInfo[0] === Dispatcher::METHOD_NOT_ALLOWED) {
throw new MethodNotAllowed;
}
if ($routeInfo[0] === Dispatcher::FOUND) {
foreach ($routeInfo[2] as $attributeName => $attributeValue) {
$request = $request->withAttribute($attributeName, $attributeValue);
}
return $request->withAttribute(
$this->routeAttributeName,
$routeInfo[1]
);
}
throw new NotFound;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
$request = $this->decorateRequest($request);
} catch (NotFound) {
$response = $this->responseFactory->createResponse(404);
$response->getBody()->write('Not Found');
return $response;
} catch (MethodNotAllowed) {
return $this->responseFactory->createResponse(405);
} catch (Throwable $t) {
throw new InternalServerError($t->getMessage(), $t->getCode(), $t);
}
if ($handler instanceof RoutedRequestHandler) {
$handler->setRouteAttributeName($this->routeAttributeName);
}
return $handler->handle($request);
}
}
```
Before we can use all the new services in our Bootstrap file we need to add the definitions to our container.
```php
[
'...',
Emitter::class => fn (BasicEmitter $e) => $e,
RoutedRequestHandler::class => fn (InvokerRoutedHandler $h) => $h,
MiddlewareInterface::class => fn (RouteMiddleware $r) => $r,
Dispatcher::class => fn (Settings $s) => simpleDispatcher(require __DIR__ . '/routes.php'),
ResponseFactoryInterface::class => fn (ResponseFactory $rf) => $rf,
],
```
And then we can update our Bootstrap.php to fetch all the services and let them handle the request.
```php
...
$routeMiddleWare = $container->get(MiddlewareInterface::class);
assert($routeMiddleWare instanceof MiddlewareInterface);
$handler = $container->get(RoutedRequestHandler::class);
assert($handler instanceof RequestHandlerInterface);
$emitter = $container->get(Emitter::class);
assert($emitter instanceof Emitter);
$request = $container->get(ServerRequestInterface::class);
assert($request instanceof ServerRequestInterface);
$response = $routeMiddleWare->process($request, $handler);
$emitter->emit($response);
```
Now we have wrapped all the important parts in our Bootstrap.php into seperate classes, but it is still quite a lot of
code and also many calls the container (and i have to write way too many docblocks to that phpstan doenst yell at me).
So we should just add another class that wraps all of our Request-Handling Classes into a clearly defined structure.
I will follow symfonys example and call this class our kernel. Before i create that class i will recap what our class
should require to function properly.
* A RequestFactory
We want our Kernel to be able to build the request itself
* An Emitter
Without an Emitter we will not be able to send the response to the client
* RouteMiddleware
To decore the request with the correct handler for the requested route
* RequestHandler
To delegate the request to the correct funtion that creates the response
As the Psr ContainerInterface leaves us to much handiwork to easily create a Serverrequest I will extend that interface
to give us easier access to a requestobject and wrap the Diactorors RequestFactory in an Adapter that satisfies our
interface:
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\ServerRequestInterface;
interface RequestFactory extends ServerRequestFactoryInterface
{
public function fromGlobals(): ServerRequestInterface;
}
```
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Laminas\Diactoros\ServerRequestFactory;
use Psr\Http\Message\ServerRequestInterface;
final class DiactorosRequestFactory implements RequestFactory
{
public function __construct(private readonly ServerRequestFactory $factory)
{
}
public function fromGlobals(): ServerRequestInterface
{
return $this->factory::fromGlobals();
}
public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface
{
return $this->factory->createServerRequest($method, $uri, $serverParams);
}
}
```
For later shenanigans I will let our Kernel implement the RequestHandlerInterface, this is how my version looks now:
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework;
use Lubian\NoFramework\Factory\RequestFactory;
use Lubian\NoFramework\Http\Emitter;
use Lubian\NoFramework\Http\RoutedRequestHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class Kernel implements RequestHandlerInterface
{
public function __construct(
private readonly RequestFactory $requestFactory,
private readonly MiddlewareInterface $routeMiddleware,
private readonly RoutedRequestHandler $handler,
private readonly Emitter $emitter,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->routeMiddleware->process($request, $this->handler);
}
public function run(): void
{
$request = $this->requestFactory->fromGlobals();
$response = $this->handle($request);
$this->emitter->emit($response);
}
}
```
We can now replace everything after the ErrorHandler in our Bootstrap.php with these few lines
```php
$app = $container->get(Kernel::class);
assert($app instanceof Kernel);
$app->run();
```
You might get some Errors here because the Container cannot resolve all the dependencies, try to fix those errors by looking
at the Whoops output and adding the needed definitions to the dependencies.php file.
And as always, don't forget to commit your changes.
[<< previous](12-configuration.md) | [next >>](14-middleware.md)

View file

@ -0,0 +1,298 @@
[<< previous](12-refactoring.md) | [next >>](14-invoker.md)
### Middleware
In the last chapter we wrote our RouterClass to implement the middleware interface, and in this chapter I want to explain
a bit more about what this interface does and why it is used in many applications.
The Middlewares are basically a number of wrappers that stand between the client and your application. Each request gets
passed through all the middlewares, gets handled by our controllers and then the response gets passed back through all
the middlewars to the client/emitter.
So every Middleware can modify the request before it goes on to the next middleware (and finally the handler) and the
response after it gets created by our handlers.
So lets take a look at the middleware and the requesthandler interfaces
```php
interface MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface;
}
interface RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface;
}
```
The RequestHandlerInterface gets only a request and returns a response, the MiddlewareInterface gets a request and a
requesthandler and returns a response. So the logical thing for the Middleware is to use the handler to produce the
response.
But the middleware could just ignore the handler and produce a response on its own as the interface just requires us
to produce a response.
A simple example for that would be a caching middleware. The basic idea is that we want to cache all request from users
that are not logged in. This way we can save a lot of processing power in rendering the html and fetching data from the
database.
In this scenario we assume that we have an authentication middleware that checks if a user is logged in and decorates
the request with an 'isAuthenticated' attribute.
If the 'isAuthenticated' attribute is set to false, we check if we have a cached response and return that, if that
response is not already cached, than we let the handler create the response and store that in the cache for a few
seconds
```php
interface CacheInterface
{
public function get(string $key, callable $resolver, int $ttl): mixed;
}
```
The first parameter is the identifier for the cache, the second is a callable that produces the value and the last one
defines the seconds that the cache should keep the item. If the cache doesnt have an item with the given key then it uses
the callable to produce the value and stores it for the time specified in ttl.
so lets write our caching middleware:
```php
final class CachingMiddleware implements MiddlewareInterface
{
public function __construct(private CacheInterface $cache){}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ($request->getAttribute('isAuthenticated', false) && $request->getMethod() === 'GET') {
$key = $request->getUri()->getPath();
return $this->cache->get($key, fn() => $handler->handle($request), 10);
}
return $handler->handle($request);
}
}
```
we can also modify the response after it has been created by our application, for example we could implement a gzip
middleware, or for more simple and silly example a middleware that adds a Dank Meme header to all our response so that the browser
know that our application is used to serve dank memes:
```php
final class DankMemeMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
return $response->withAddedHeader('Meme', 'Dank');
}
}
```
but for our application we are going to just add two external middlewares:
* [Trailing-slash](https://github.com/middlewares/trailing-slash) to remove the trailing slash from all routes.
* [whoops middleware](https://github.com/middlewares/whoops) to wrap our error handler into a nice middleware
```bash
composer require middlewares/trailing-slash
composer require middlewares/whoops
```
The whoops middleware should be the first middleware to be executed so that we catch any errors that are thrown in the
application as well as the middleware stack.
Our desired request -> response flow looks something like this:
Client
| ^
v |
Kernel
| ^
v |
Whoops Middleware
| ^
v |
TrailingSlash
| ^
v |
Routing
| ^
v |
ContainerResolver
| ^
v |
Controller/Action
As every middleware expects a RequestHandlerInterface as its second argument we need some extra code that wraps every
middleware as a RequestHandler and chains them together with the ContainerRouteDecoratedResolver as the last Handler.
```php
interface Pipeline
{
public function dispatch(ServerRequestInterface $request): ResponseInterface;
}
```
And our implementation looks something like this:
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Http;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use function array_reverse;
use function assert;
use function is_string;
class ContainerPipeline implements Pipeline
{
/**
* @param array<MiddlewareInterface|class-string> $middlewares
* @param RequestHandlerInterface $tip
* @param ContainerInterface $container
*/
public function __construct(
private array $middlewares,
private RequestHandlerInterface $tip,
private ContainerInterface $container,
) {
}
public function dispatch(ServerRequestInterface $request): ResponseInterface
{
$this->buildStack();
return $this->tip->handle($request);
}
private function buildStack(): void
{
foreach (array_reverse($this->middlewares) as $middleware) {
$next = $this->tip;
if ($middleware instanceof MiddlewareInterface) {
$this->tip = $this->wrapMiddleware($middleware, $next);
}
if (is_string($middleware)) {
$this->tip = $this->wrapResolvedMiddleware($middleware, $next);
}
}
}
private function wrapResolvedMiddleware(string $middleware, RequestHandlerInterface $next): RequestHandlerInterface
{
return new class ($middleware, $next, $this->container) implements RequestHandlerInterface {
public function __construct(
private readonly string $middleware,
private readonly RequestHandlerInterface $handler,
private readonly ContainerInterface $container,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$middleware = $this->container->get($this->middleware);
assert($middleware instanceof MiddlewareInterface);
return $middleware->process($request, $this->handler);
}
};
}
private function wrapMiddleware(MiddlewareInterface $middleware, RequestHandlerInterface $next): RequestHandlerInterface
{
return new class ($middleware, $next) implements RequestHandlerInterface {
public function __construct(
private readonly MiddlewareInterface $middleware,
private readonly RequestHandlerInterface $handler,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->middleware->process($request, $this->handler);
}
};
}
}
```
Here we define our constructor to require two arguments: an array of middlewares and a requesthandler as the final code
that should produce our response.
In the buildStack() method we wrap every middleware as a RequestHandler with the current tip property as the $next argument
and store that itself as the current tip.
There are of course a lot of more sophisticated ways to build a pipeline/dispatcher that you can check out at the [middlewares github](https://github.com/middlewares/awesome-psr15-middlewares#dispatcher)
Lets add a simple factory to our dependencies.php file that creates our middlewarepipeline
Lets create a simple Factory that loads an Array of Middlewares from the Config folder and uses that to build our pipeline
```php
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Lubian\NoFramework\Http\ContainerPipeline;
use Lubian\NoFramework\Http\Pipeline;
use Lubian\NoFramework\Http\RoutedRequestHandler;
use Lubian\NoFramework\Settings;
use Psr\Container\ContainerInterface;
class PipelineProvider
{
public function __construct(
private Settings $settings,
private RoutedRequestHandler $tip,
private ContainerInterface $container,
) {
}
public function getPipeline(): Pipeline
{
$middlewares = require $this->settings->middlewaresFile;
return new ContainerPipeline($middlewares, $this->tip, $this->container);
}
}
```
And configure the container to use the Factory to create the Pipeline:
```php
...,
Pipeline::class => fn (PipelineProvider $p) => $p->getPipeline(),
...
```
And of course a new file called middlewares.php in our config folder:
```php
<?php declare(strict_types=1);
use Lubian\NoFramework\Http\RouteMiddleware;
use Middlewares\TrailingSlash;
use Middlewares\Whoops;
return [
Whoops::class,
TrailingSlash::class,
RouteMiddleware::class,
];
```
And we need to add the pipeline to our Kernel class. I will leave that as an exercise to you, a simple hint that i can
give you is that the handle()-method of the Kernel should look like this:
```php
public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->pipeline->dispatch($request);
}
```
Lets try if you can make the kernel work with our created Pipeline implementation. For the future we could improve our
pipeline a little bit, so that it can accept a class-string of a middleware and resolves that with the help of a
dependency container, if you want you can do that as well.
[<< previous](12-refactoring.md) | [next >>](14-invoker.md)

View file

@ -0,0 +1,7 @@
parameters:
ignoreErrors:
-
message: "#^Parameter \\#1 \\$callable of method Invoker\\\\InvokerInterface\\:\\:call\\(\\) expects array\\|\\(callable\\(\\)\\: mixed\\)\\|string, mixed given\\.$#"
count: 1
path: src/Http/InvokerRoutedHandler.php

View file

@ -0,0 +1,8 @@
includes:
- phpstan-baseline.neon
parameters:
level: max
paths:
- src
- config

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../src/Bootstrap.php';

View file

View file

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Action;
use Lubian\NoFramework\Service\Time\Now;
use Lubian\NoFramework\Template\Renderer;
use Psr\Http\Message\ResponseInterface;
final class Hello
{
public function __invoke(
ResponseInterface $response,
Now $now,
Renderer $renderer,
string $name = 'Stranger',
): ResponseInterface {
$body = $response->getBody();
$data = [
'now' => $now()->format('H:i:s'),
'name' => $name,
];
$content = $renderer->render('hello', $data);
$body->write($content);
return $response
->withStatus(200)
->withBody($body);
}
}

View file

@ -0,0 +1,19 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Action;
use Psr\Http\Message\ResponseInterface;
final class Other
{
public function someFunctionName(ResponseInterface $response): ResponseInterface
{
$body = $response->getBody();
$body->write('This works too!');
return $response
->withStatus(200)
->withBody($body);
}
}

View file

@ -0,0 +1,35 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Action;
use Lubian\NoFramework\Repository\MarkdownPageRepo;
use Lubian\NoFramework\Template\Renderer;
use Parsedown;
use Psr\Http\Message\ResponseInterface;
use function preg_replace;
use function str_replace;
class Page
{
public function __invoke(
string $page,
ResponseInterface $response,
MarkdownPageRepo $repo,
Parsedown $parsedown,
Renderer $renderer,
): ResponseInterface {
$page = $repo->byTitle($page);
$content = $this->linkFilter($page->content);
$content = $parsedown->parse($content);
$html = $renderer->render('page', ['content' => $content, 'title' => $page->title]);
$response->getBody()->write($html);
return $response;
}
private function linkFilter(string $content): string
{
$content = preg_replace('/\(\d\d-/m', '(', $content);
return str_replace('.md)', ')', $content);
}
}

View file

@ -0,0 +1,40 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework;
use Lubian\NoFramework\Factory\FileSystemSettingsProvider;
use Lubian\NoFramework\Factory\SettingsContainerProvider;
use Throwable;
use Whoops\Handler\PrettyPageHandler;
use Whoops\Run;
use function assert;
use function error_log;
use function error_reporting;
use const E_ALL;
require __DIR__ . '/../vendor/autoload.php';
error_reporting(E_ALL);
$settingsProvider = new FileSystemSettingsProvider(__DIR__ . '/../config/settings.php');
$container = (new SettingsContainerProvider($settingsProvider))->getContainer();
$settings = $settingsProvider->getSettings();
$whoops = new Run;
if ($settings->environment === 'dev') {
$whoops->pushHandler(new PrettyPageHandler);
} else {
$whoops->pushHandler(function (Throwable $e): void {
error_log('Error: ' . $e->getMessage(), (int) $e->getCode());
echo 'An Error happened';
});
}
$whoops->register();
$app = $container->get(Kernel::class);
assert($app instanceof Kernel);
$app->run();

View file

@ -0,0 +1,9 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Exception;
use Exception;
final class InternalServerError extends Exception
{
}

View file

@ -0,0 +1,9 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Exception;
use Exception;
final class MethodNotAllowed extends Exception
{
}

View file

@ -0,0 +1,9 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Exception;
use Exception;
final class NotFound extends Exception
{
}

View file

@ -0,0 +1,10 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Psr\Container\ContainerInterface;
interface ContainerProvider
{
public function getContainer(): ContainerInterface;
}

View file

@ -0,0 +1,28 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Laminas\Diactoros\ServerRequestFactory;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
final class DiactorosRequestFactory implements RequestFactory
{
public function __construct(private readonly ServerRequestFactory $factory)
{
}
public function fromGlobals(): ServerRequestInterface
{
return $this->factory::fromGlobals();
}
/**
* @param UriInterface|string $uri
* @param array<mixed> $serverParams
*/
public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface
{
return $this->factory->createServerRequest($method, $uri, $serverParams);
}
}

View file

@ -0,0 +1,22 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Lubian\NoFramework\Settings;
use function assert;
final class FileSystemSettingsProvider implements SettingsProvider
{
public function __construct(
private string $filePath
) {
}
public function getSettings(): Settings
{
$settings = require $this->filePath;
assert($settings instanceof Settings);
return $settings;
}
}

View file

@ -0,0 +1,25 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Lubian\NoFramework\Http\ContainerPipeline;
use Lubian\NoFramework\Http\Pipeline;
use Lubian\NoFramework\Http\RoutedRequestHandler;
use Lubian\NoFramework\Settings;
use Psr\Container\ContainerInterface;
class PipelineProvider
{
public function __construct(
private Settings $settings,
private RoutedRequestHandler $tip,
private ContainerInterface $container,
) {
}
public function getPipeline(): Pipeline
{
$middlewares = require $this->settings->middlewaresFile;
return new ContainerPipeline($middlewares, $this->tip, $this->container);
}
}

View file

@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\ServerRequestInterface;
interface RequestFactory extends ServerRequestFactoryInterface
{
public function fromGlobals(): ServerRequestInterface;
}

View file

@ -0,0 +1,25 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use DI\ContainerBuilder;
use Lubian\NoFramework\Settings;
use Psr\Container\ContainerInterface;
final class SettingsContainerProvider implements ContainerProvider
{
public function __construct(
private SettingsProvider $settingsProvider,
) {
}
public function getContainer(): ContainerInterface
{
$builder = new ContainerBuilder;
$settings = $this->settingsProvider->getSettings();
$dependencies = require $settings->dependenciesFile;
$dependencies[Settings::class] = fn (): Settings => $settings;
$builder->addDefinitions($dependencies);
return $builder->build();
}
}

View file

@ -0,0 +1,10 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Factory;
use Lubian\NoFramework\Settings;
interface SettingsProvider
{
public function getSettings(): Settings;
}

View file

@ -0,0 +1,38 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Http;
use Psr\Http\Message\ResponseInterface;
use function header;
use function sprintf;
use function strtolower;
final class BasicEmitter implements Emitter
{
public function emit(ResponseInterface $response, bool $withoutBody = false): void
{
foreach ($response->getHeaders() as $name => $values) {
$first = strtolower($name) !== 'set-cookie';
foreach ($values as $value) {
$header = sprintf('%s: %s', $name, $value);
header($header, $first);
$first = false;
}
}
$statusLine = sprintf(
'HTTP/%s %s %s',
$response->getProtocolVersion(),
$response->getStatusCode(),
$response->getReasonPhrase()
);
header($statusLine, true, $response->getStatusCode());
if ($withoutBody) {
return;
}
echo $response->getBody();
}
}

View file

@ -0,0 +1,82 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Http;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use function array_reverse;
use function assert;
use function is_string;
class ContainerPipeline implements Pipeline
{
/**
* @param array<MiddlewareInterface|class-string> $middlewares
* @param RequestHandlerInterface $tip
* @param ContainerInterface $container
*/
public function __construct(
private array $middlewares,
private RequestHandlerInterface $tip,
private ContainerInterface $container,
) {
}
public function dispatch(ServerRequestInterface $request): ResponseInterface
{
$this->buildStack();
return $this->tip->handle($request);
}
private function buildStack(): void
{
foreach (array_reverse($this->middlewares) as $middleware) {
$next = $this->tip;
if ($middleware instanceof MiddlewareInterface) {
$this->tip = $this->wrapMiddleware($middleware, $next);
}
if (is_string($middleware)) {
$this->tip = $this->wrapResolvedMiddleware($middleware, $next);
}
}
}
private function wrapResolvedMiddleware(string $middleware, RequestHandlerInterface $next): RequestHandlerInterface
{
return new class ($middleware, $next, $this->container) implements RequestHandlerInterface {
public function __construct(
private readonly string $middleware,
private readonly RequestHandlerInterface $handler,
private readonly ContainerInterface $container,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$middleware = $this->container->get($this->middleware);
assert($middleware instanceof MiddlewareInterface);
return $middleware->process($request, $this->handler);
}
};
}
private function wrapMiddleware(MiddlewareInterface $middleware, RequestHandlerInterface $next): RequestHandlerInterface
{
return new class ($middleware, $next) implements RequestHandlerInterface {
public function __construct(
private readonly MiddlewareInterface $middleware,
private readonly RequestHandlerInterface $handler,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->middleware->process($request, $this->handler);
}
};
}
}

View file

@ -0,0 +1,10 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Http;
use Psr\Http\Message\ResponseInterface;
interface Emitter
{
public function emit(ResponseInterface $response, bool $withoutBody = false): void;
}

View file

@ -0,0 +1,34 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Http;
use Invoker\InvokerInterface;
use Lubian\NoFramework\Exception\InternalServerError;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class InvokerRoutedHandler implements RoutedRequestHandler
{
public function __construct(
private readonly InvokerInterface $invoker,
private string $routeAttributeName = '__route_handler',
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$handler = $request->getAttribute($this->routeAttributeName, false);
$vars = $request->getAttributes();
$vars['request'] = $request;
$response = $this->invoker->call($handler, $vars);
if (! $response instanceof ResponseInterface) {
throw new InternalServerError('Handler returned invalid response');
}
return $response;
}
public function setRouteAttributeName(string $routeAttributeName = '__route_handler'): void
{
$this->routeAttributeName = $routeAttributeName;
}
}

View file

@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Http;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
interface Pipeline
{
public function dispatch(ServerRequestInterface $request): ResponseInterface;
}

View file

@ -0,0 +1,69 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Http;
use FastRoute\Dispatcher;
use Lubian\NoFramework\Exception\InternalServerError;
use Lubian\NoFramework\Exception\MethodNotAllowed;
use Lubian\NoFramework\Exception\NotFound;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Throwable;
final class RouteMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly Dispatcher $dispatcher,
private readonly ResponseFactoryInterface $responseFactory,
private readonly string $routeAttributeName = '__route_handler',
) {
}
private function decorateRequest(
ServerRequestInterface $request,
): ServerRequestInterface {
$routeInfo = $this->dispatcher->dispatch(
$request->getMethod(),
$request->getUri()->getPath(),
);
if ($routeInfo[0] === Dispatcher::METHOD_NOT_ALLOWED) {
throw new MethodNotAllowed;
}
if ($routeInfo[0] === Dispatcher::FOUND) {
foreach ($routeInfo[2] as $attributeName => $attributeValue) {
$request = $request->withAttribute($attributeName, $attributeValue);
}
return $request->withAttribute(
$this->routeAttributeName,
$routeInfo[1]
);
}
throw new NotFound;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
$request = $this->decorateRequest($request);
} catch (NotFound) {
$response = $this->responseFactory->createResponse(404);
$response->getBody()->write('Not Found');
return $response;
} catch (MethodNotAllowed) {
return $this->responseFactory->createResponse(405);
} catch (Throwable $t) {
throw new InternalServerError($t->getMessage(), $t->getCode(), $t);
}
if ($handler instanceof RoutedRequestHandler) {
$handler->setRouteAttributeName($this->routeAttributeName);
}
return $handler->handle($request);
}
}

View file

@ -0,0 +1,10 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Http;
use Psr\Http\Server\RequestHandlerInterface;
interface RoutedRequestHandler extends RequestHandlerInterface
{
public function setRouteAttributeName(string $routeAttributeName = '__route_handler'): void;
}

View file

@ -0,0 +1,32 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework;
use Lubian\NoFramework\Factory\RequestFactory;
use Lubian\NoFramework\Http\Emitter;
use Lubian\NoFramework\Http\Pipeline;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class Kernel implements RequestHandlerInterface
{
public function __construct(
private readonly RequestFactory $requestFactory,
private readonly Pipeline $pipeline,
private readonly Emitter $emitter,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->pipeline->dispatch($request);
}
public function run(): void
{
$request = $this->requestFactory->fromGlobals();
$response = $this->handle($request);
$this->emitter->emit($response);
}
}

View file

@ -0,0 +1,36 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Middleware;
use Laminas\Diactoros\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use function base64_encode;
final class CacheMiddleware implements MiddlewareInterface
{
public function __construct(private CacheInterface $cache, private Response\Serializer $serializer)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ($request->getMethod() === 'GET') {
$key = (string) $request->getUri();
$key = base64_encode($key);
$callback = fn () => $handler->handle($request);
$cached = $this->cache->get($key, function (ItemInterface $item) use ($callback) {
$item->expiresAfter(120);
$response = $callback();
return $this->serializer::toString($response);
});
return $this->serializer::fromString($cached);
}
return $handler->handle($request);
}
}

View file

@ -0,0 +1,13 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Model;
class MarkdownPage
{
public function __construct(
public readonly int $id,
public readonly string $title,
public readonly string $content,
) {
}
}

View file

@ -0,0 +1,46 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Repository;
use Lubian\NoFramework\Model\MarkdownPage;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class CachedMarkdownPageRepo implements MarkdownPageRepo
{
public function __construct(
private CacheInterface $cache,
private MarkdownPageRepo $repo,
) {
}
/**
* @inheritDoc
*/
public function all(): array
{
$callback = fn () => $this->repo->all();
return $this->cache->get('ALLPAGES', function (ItemInterface $item) use ($callback) {
$item->expiresAfter(30);
return $callback();
});
}
public function byId(int $id): MarkdownPage
{
$callback = fn () => $this->repo->byId($id);
return $this->cache->get('PAGE' . $id, function (ItemInterface $item) use ($callback) {
$item->expiresAfter(30);
return $callback();
});
}
public function byTitle(string $title): MarkdownPage
{
$callback = fn () => $this->repo->byTitle($title);
return $this->cache->get('PAGE' . $title, function (ItemInterface $item) use ($callback) {
$item->expiresAfter(30);
return $callback();
});
}
}

View file

@ -0,0 +1,63 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Repository;
use Lubian\NoFramework\Exception\NotFound;
use Lubian\NoFramework\Model\MarkdownPage;
use function array_filter;
use function array_map;
use function array_values;
use function assert;
use function count;
use function file_get_contents;
use function glob;
use function is_array;
use function str_replace;
use function substr;
use function usleep;
final class MarkdownPageFilesystem implements MarkdownPageRepo
{
public function __construct(private readonly string $dataPath)
{
}
/**
* @return MarkdownPage[]
*/
public function all(): array
{
$fileNames = glob($this->dataPath . '*.md');
assert(is_array($fileNames));
return array_map(function (string $name): MarkdownPage {
usleep(100000);
$content = file_get_contents($name);
$name = str_replace($this->dataPath, '', $name);
$name = str_replace('.md', '', $name);
$id = (int) substr($name, 0, 2);
$title = substr($name, 3);
return new MarkdownPage($id, $title, $content);
}, $fileNames);
}
public function byId(int $id): MarkdownPage
{
$callback = fn (MarkdownPage $p): bool => $p->id === $id;
$filtered = array_values(array_filter($this->all(), $callback));
if (count($filtered) === 0) {
throw new NotFound;
}
return $filtered[0];
}
public function byTitle(string $title): MarkdownPage
{
$callback = fn (MarkdownPage $p): bool => $p->title === $title;
$filtered = array_values(array_filter($this->all(), $callback));
if (count($filtered) === 0) {
throw new NotFound;
}
return $filtered[0];
}
}

View file

@ -0,0 +1,17 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Repository;
use Lubian\NoFramework\Model\MarkdownPage;
interface MarkdownPageRepo
{
/**
* @return MarkdownPage[]
*/
public function all(): array;
public function byId(int $id): MarkdownPage;
public function byTitle(string $title): MarkdownPage;
}

View file

@ -0,0 +1,10 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Service\Time;
use DateTimeImmutable;
interface Now
{
public function __invoke(): DateTimeImmutable;
}

View file

@ -0,0 +1,13 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Service\Time;
use DateTimeImmutable;
final class SystemClockNow implements Now
{
public function __invoke(): DateTimeImmutable
{
return new DateTimeImmutable;
}
}

View file

@ -0,0 +1,16 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework;
final class Settings
{
public function __construct(
public readonly string $environment,
public readonly string $dependenciesFile,
public readonly string $middlewaresFile,
public readonly string $templateDir,
public readonly string $templateExtension,
public readonly string $pagesPath,
) {
}
}

View file

@ -0,0 +1,17 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Template;
use Mustache_Engine;
final class MustacheRenderer implements Renderer
{
public function __construct(private Mustache_Engine $engine)
{
}
public function render(string $template, array $data = []): string
{
return $this->engine->render($template, $data);
}
}

View file

@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace Lubian\NoFramework\Template;
interface Renderer
{
/**
* @param array<string, mixed> $data
*/
public function render(string $template, array $data = []): string;
}

View file

@ -0,0 +1,6 @@
{{> partials/head }}
<div class="column col-auto">
<h1>Hello {{name}}</h1>
<p>The time is {{now}}</p>
</div>
{{> partials/foot }}

View file

@ -0,0 +1,5 @@
{{> partials/head }}
<div class="column col-6 col-mx-auto">
{{{content}}}
</div>
{{> partials/foot }}

View file

@ -0,0 +1,3 @@
</div>
</body>
</html>

View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>No Framework: {{title}}</title>
<link rel="stylesheet" href="/css/spectre.min.css">
<link rel="stylesheet" href="/css/spectre-exp.min.css">
<link rel="stylesheet" href="/css/spectre-icons.min.css">
</head>
<body class="container">
<div class="columns">