Сайт вебмастера

PHP-фреймворк Slim 4

04-01-2022Время чтения ~ 34 мин.PHP 934

Решил опубликовать большой (!) лонгрид посвященный php-фреймворку Slim 4. Это микрофреймворк, а значит он содержит лишь самый минимум возможностей. С другой стороны Slim играет на поле full-stack фреймворков, таких как Symfony, Laravel и CodeIgniter.

Особенность Slim в том, что он по сути является лишь связующим звеном между сторонними библиотеками, которые созданы по стандартам PSR. Мы давно уже с этим сталкиваемся — сейчас доступно много хороших библиотек, которые можно использовать из коробки. Например для логов — Monolog, для контейнера — PHP-DI, для роутинга — FastRoute и т.д. Вот с помощью Slim можно всё это соединить воедино.

Но есть нюанс. Slim, хоть и небольшой фреймворк, но достаточно сложный. В основном мы привыкли работать в концепции MVC, где всё упирается в отдельные файлы контролёра, модели и шаблона, но вот Slim — это не MVC. Это ломает привычный подход и в процессе работы со Slim часто ловишь себя на мысли — зачем так усложнять, ведь можно сделать проще. :-)

Поэтому прежде чем приступать к кодированию, нужно разобраться с основными концепциями, лежащими в основе фреймворка.

Исторически сложилось так, что у нас сейчас доминируют три фреймворка.

CodeIgniter можно считать классическим MVC-фреймворком. Мы можем закрыть глаза на некоторые его «особенности» в понимании Model, поэтому в целом его работа будет строиться следующим образом:

  • Получить входящий URL.
  • Взять из конфигурации список маршрутов (роутинг).
  • Найти соответствующий текущему URL класс контролёра.
  • Передать управление этому контролёру.

Входящий URL представляет собой т.н. сегменты (разделитель «/»). Отсюда и строится приложение — каждому сегменту будет соответствовать «контролёр/метод». Да, CodeIgniter, конечно же позволяет делать и другие варианты роутинга, но в целом используется именно такой подход. Он простой и понятный.

В противоположность ему можно поставить фреймворк Symfony. Это фреймворк оперирует понятиями http-«запрос-ответ» request/response. То есть на определённый http-запрос формируется http-ответ. И хотя по сути ответ формирует php-класс, который мы можем назвать контролёром, но на самом деле здесь больше подходит название Action (действие). Получается, что http-запрос вызывает какое-то действие, которое в свою очередь формирует http-ответ.

И уже не важно, будет ли этот Action выполнен как MVC или в любой другой структуре. Такой подход намного гибче «классического» MVC, поскольку очень много задач можно решать без Модели и Представления (view). Даже его автор определяет Symfony как HTTP-фреймворк. По современным представлениям это ближе к модели ADR (Action Domain Responder).

И казалось бы, давайте все перейдём на Symfony, если он такой классный. Но здесь «засада» — он слишком сложный. Например фреймворк очень активно использует аннотации. Например вместо файла с маршрутами роутинга — они прописываются в виде php-комментариев к нужному php-классу. Тоже самое для работы с базой данных. Для вывода испольузется тяжелый и неуклюжий шаблонизатор Twig. Кроме того для работы с Symfony используется консоль, с помощью которой генерируются php-файлы или выполняются какие-то действия. Получается, что фреймворк работает как «черный ящик» — всё слишком автоматизировано и понять что происходит достаточно проблематично.

Именно поэтому Symfony, хоть и имеет красивую теорию, годится только для больших команд и сложных проектов. Однако особо стоит отметить, что Symfony состоит из множества готовых и самостоятельных компонентов. Я думаю, что именно эти компоненты дали популярность фреймворку.

Фреймворк Laravel занимает некое промежуточное положение. Изначальный форк CodeIgniter, постепенно начал мигрировать в сторону HTTP-фреймворка (естественно под влиянием Symfony). Laravel начинает работать «как Symfony» — роутинг строится по принципу http-«запрос-ответ». В отличие от Symfony, здесь уже используется явное указание роутов: маршрут — callback-функция. Это намного удобней, чем искать аннотации.

После этого подключает контролёр (по сути Action) и дальше вывод строится по концепции MVC. При этом в качестве представления (View) используется шаблонизатор Blade либо «голый» php-файл. То есть Laravel придерживается достаточно жесткой структуры каталогов и файлов (как и CodeIgniter), но это упрощает создание приложения — достаточно знать где и что нужно менять и хранить.

Именно из-за этого Laravel более привлекателен для php-разработчиков — в нём разобраться намного проще.

С другой стороны Laravel — это full-stack фреймворк, а значит содержит много файлов. Начальное приложение занимает 36Мб на диске (из них FakerPHP - 10Мб). Таким образом использование Laravel оправдано только для больших и сложных сайтов. Дело не спасает и их «дочерний» фреймворк Lumen — он базируется абсолютно на тех же самых библиотеках, что и Laravel.

Кстати похожая ситуация была и с Silex — «дочерний» фрейморк Symfony. Несколько лет назад его закрыли.

Что получается в итоге? Когда-то давно фреймворки действительно были небольшими, но сейчас сильно «разжирели» (кроме CodeIgniter) и использовать их для небольшого сайта слишком затратно.

Вот здесь в игру и вступает Slim. Это HTTP-фреймворк, где роутинг - является основой приложения. Сам роутинг описывается простым «маршрут — callback-функция». В качестве роутинга используется библиотека FastRoute, что покрывает большинство потребностей.

Современное приложение использует контейнер зависимостей. Есть «родной» сервис-контейнер Slim, но он слишком уж примитивный, поэтому можно использовать PHP-DI.

Slim оперирует понятием Middleware — это т.н. промежуточный слой. Например мы хотим добавить к какому-то маршруту дополнительные данные или провести проверку. Вот именно в Middleware такие вещи и происходят.

Работа со Slim происходит примерно так:

  1. Создаём экземпляр приложения — по сути это объект роутинга.
  2. Создаём DI-контейнер, который будет хранилищем зависимостей.
  3. Если нужно формируем Middleware.
  4. Определяем маршруты — задаются правила роутинга.
  5. Запускаем приложение.

Slim после инициализации, создаст объекты Request и Response и передаст их в Action, который определен в роутинге. Если у этого маршрута определён Middleware, то он также будет выполнен через его обработчик (handle). На выходе будет сформирован (на основе созданного Response) http-ответ, который и будет отправлен браузеру.

Slim вообще никак не регламентирует где хранить конфигурацию, контролёры, логи и т.д., поэтому структура приложения может быть произвольной.

Но Slim заставляет достаточно жестко придерживаться стандартов PSR: основной это PSR-7 который указывает как именно работать с http-сообщениями. На практике такой подход выражается в строгой типизации параметров Action — на входе нужно принять запрос с типом ServerRequestInterface, ответ с типом ResponseInterface и на выходе отдать объект ResponseInterface. Именно это позволяет строить произвольные цепочки вызовов.

Поскольку сам по себе Slim небольшой, то для полноценного приложения нужно будет использовать Composer. Приятным бонусом будет его поддержка PSR-4 (автозагрузка классов). То есть в теории мы можем вообще всё сделать в одном index.php, а библиотеки будут в каталоге vendor. На практике, конечно же нам всё равно придётся писать свой код, поэтому есть смысл определиться с каталогами будущего приложения. Но это мы сделаем позже, вначале просто поставим Slim и запустим его на локальном сервере. Для этого нам нужен Composer.

Я буду устанавливать на свой локальный сервер http://demo/slim1 (потом slim2). Вы можете сделать аналогично, или используйте другой хост.

1. Переходим в каталог сайта и вызываем консоль. Запускаем команду, которая установит Slim

composer require slim/slim:"4.*"

Если Composer спросит «No composer.json in current directory, do you want to use the one at D:\domains\demo? [Y,n]?» выберите «n».

После установки должен появиться каталог vendor и файлы composer.json и composer.lock.

2. Для работы Slim нужна какая-то PSR-7 библиотека. Будем использовать родную от Slim. Выполним:

composer require slim/psr7

3. Теперь здесь же создадим файл .htaccess

Options +SymLinksIfOwnerMatch
Options -Indexes
DirectoryIndex index.php
AddDefaultCharset UTF-8
 
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
</IfModule>

Это подключит ЧПУ.

4. Теперь сделаем index.php

<?php
 
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Slim\Factory\AppFactory;
 
// подключаем Composer
require 'vendor/autoload.php';
 
// создаём объект приложения
$app = AppFactory::create();
 
// указываем базовый путь для роутинга — поскольку это подкаталог
$app->setBasePath('/slim1');
 
// http://demo/slim1/
$app->get('/', function (ServerRequestInterface $request, ResponseInterface $response, $args) {
    $response->getBody()->write('Home page');
    return $response;
});
 
// http://demo/slim1/hello
$app->get('/hello', function (ServerRequestInterface $request, ResponseInterface $response, $args) {
    $response->getBody()->write('Hello world!');
    return $response;
});
 
// запускаем приложение
$app->run();
 
# end of file

5. Наберите в браузере http://demo/slim1/ и http://demo/slim1/hello — увидим соответствующее сообщение.

Обратите внимание на строчку с setBasePath(). Она нужна для случаев, если сайт располагается не в корне домена. Если у вас другой вариант, то измените эту строчку.

Давайте рассмотрим этот код.

Секция «use» показывает какие классы будут использоваться. Дальше мы подключаем автозагрузку файлов, который создал Composer.

Следующая строчка — это создание экземпляра приложения. Под капотом Slim использует FastRoute, но нигде этого указывать не нужно — вместо него используем оболочку AppFactory, которая хранит объекты роутинга, контейнера и т.д.

Маршруты указываются в виде http-методов GET, POST и т.п. Поэтому $app->get() — это GET-запрос. В нём нужно указать шаблон URL и callback-функцию (Action). Эта функция принимает три параметра. Первый — это входящий запрос ServerRequestInterface, второй — это текущий ответ с типом ResponseInterface, а третий — дополнительные аргументы, которые могут быть в шаблоне URL.

Внутри action-функции будут доступны два объекта $request и $response. В нашем примере $response формируется как тело ответа, куда мы отправляем простой текст. И в завершении функция возвращает объект $response для того, чтобы обеспечить дальнейшее выполнение цепочки запросов.

Как только роуты будут определены, можно запустить приложение с помощью метода run().

Основы Middleware

Middleware переводят по разному. Это и посредники, и промежуточное ПО. С помощью Middleware можно внедрится в http-ответ. Причем можно это сделать на глобальном уровне, или локально — для какого-то одного маршрута.

Использовать ли Middleware зависит только от разработчика. Можно вполне спокойно обойтись и без них, а можно всё приложение сделать на Middleware. В Slim http-ответ формируется в Action, но вначале он проходит через слой Middleware, которые могут его изменить. В этом и есть их основное предназначение.

Конкретно в Slim 4 роутинг — это тоже Middleware, только на самом глубоком уровне. Поэтому вначале «срабатывает» слой Middleware, а потом слой роутинга.

Давайте для примера сделаем один Middleware. Это обычная функция, которая должна поддерживать интерфейс PSR-15.

Первый аргумент — это http-запрос с типом ServerRequestInterface. Второй аргумент — это handler-обработчик RequestHandlerInterface. На выходе функции должен быть ответ с типом ResponseInterface.

Я приведу весь код index.php, чтобы вы не запутались:

<?php
 
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Slim\Factory\AppFactory;
use Psr\Http\Server\RequestHandlerInterface; // обработчик Middleware
 
// подключаем Composer
require 'vendor/autoload.php';
 
// создаём объект приложения
$app = AppFactory::create();
 
// указываем базовый путь для роутинга — поскольку это подкаталог
$app->setBasePath('/slim1');
 
// создадим свой Middleware
$myMiddleware = function (ServerRequestInterface $request, RequestHandlerInterface $handler) {
    $response = $handler->handle($request);
    $response->getBody()->write(' myMiddleware ');
    return $response;
};
 
// добавим его в приложение
$app->add($myMiddleware);
 
 
// http://demo/slim1/
$app->get('/', function (ServerRequestInterface $request, ResponseInterface $response, $args) {
    $response->getBody()->write('Home page');
    return $response;
});
 
// http://demo/slim1/hello
$app->get('/hello', function (ServerRequestInterface $request, ResponseInterface $response, $args) {
    $response->getBody()->write('Hello world!');
    return $response;
});
 
// запускаем приложение
$app->run();

# end of file

Теперь, если обновить страницы в браузере, мы увидим приписку « myMiddleware ». Здесь мы добавили глобальный Middleware с помощью $app->add().

Попробуем добавить этот же Middleware только к одному маршруту.

Закомментируйте строчку с $app->add() и добавьте его к маршруту:

// http://demo/slim1/
$app->get('/', function (ServerRequestInterface $request, ResponseInterface $response, $args) {
    $response->getBody()->write('Home page');
    return $response;
})->add($myMiddleware);

Теперь приписка « myMiddleware » будет срабатывать только для главной страницы сайта.

Дальше рассмотрим ещё одну возможность Slim — это отлов 404-страницы.

Для начала наберите в браузере несуществующий адрес, например http://demo/slim1/news. В браузере появится php-ошибка «Fatal error: Uncaught Slim\Exception\HttpNotFoundException: Not found ...». Чтобы от неё избавиться нужно добавить обработчик ErrorMiddleware, который будет перехватывать эту ошибку и выводить что-то более приемлемое.

$app->addErrorMiddleware(false, false, false);

Метод addErrorMiddleware() принимает три обязательных параметра. Первый из них отвечает за вывод подробной информации об ошибке. Для этого его нужно указать как true. Естественно на рабочем сайте этого не стоит делать.

Обработчик ErrorMiddleware можно заменить на другой вариант. Он пригодится для формирования своей 404-страницы. Такой код мы рассмотрим позже, когда будем строить своё приложение на основе классов.

Что будет если добавить несколько Middleware?

Сделайте копии $myMiddleware как $myMiddleware1 и $myMiddleware2 и добавьте к приложению:

$app->add($myMiddleware1);
$app->add($myMiddleware2);

После обновления страницы получится «Home page myMiddleware1 myMiddleware2». А теперь поменяйте порядок добавления:

$app->add($myMiddleware2);
$app->add($myMiddleware1);

Получится «Home page myMiddleware2 myMiddleware1». Этот пример показывает важность порядка добавления Middleware.

Теперь давайте вернёмся к роутингу. Помимо фиксированного URL можно указывать определённый паттерн. Например если мы хотим перехватить все неизвестные адреса, то можем использовать именованный параметр для главной:

// http://demo/slim1/
$app->get('/', ...
 
// http://demo/slim1/hello
$app->get('/hello', ...
 
// всё остальное
$app->get('/{slug}', function (ServerRequestInterface $request, ResponseInterface $response, $args) {
    $response->getBody()->write('This page is ' . $args['slug']);
    return $response;
}); 

В последнем роуте используется шаблон с подстановкой {slug}, который автоматически будет хранится в параметре $args. Это обычный массив, где будет ключ «slug». Мы его и выводим в браузере, если набрать любой несуществующий адрес. Такой приём позволяет перехватить 404-страницу без использования дополнительных Middleware.

При создании роутов нужно учитывать их порядок. Может случиться так, что маршруты будут перекрывать друг-друга, а значит Slim не сможет получить единственный обработчик и появится сообщение об ошибке «Slim Application Error». Для примера переместите последний роут в начало:

// всё остальное
$app->get('/{slug}', ...
 
// http://demo/slim1/
$app->get('/', ...
 
// http://demo/slim1/hello
$app->get('/hello', ...

Здесь мы получим ошибку, поскольку первый роут перекрывает остальные. Нужно за этим следить.

DI-контейнер

Ещё одной архитектурной концепцией Slim является работа с Dependency Container. Такой контейнер должен реализовывать PSR-11 и часто используется PHP-DI. Контейнер нужен для внедрения зависимостей. Я об этом уже рассказывал раньше. В простом виде это что-то вроде сервисов, которые будут доступны в разных частях приложения.

Контейнер привязывается к основному объекту приложения $app, поэтому, если в каком-то другом классе нужно использовать контейнер, то его можно получить из $app.

Для начала нам потребуется установить PHP-DI с помощью Composer. Наберите в консоли:

composer require php-di/php-di --with-all-dependencies
Ключ «--with-all-dependencies» нужен для того, что Slim и PHP-DI используют одни и те же PSR-интерфейсы, но в PHP-DI указана зависимость от более низкой версии.

Теперь подключаем его в index.php. Я опять привожу полный код, чтобы никто не запутался:

<?php
 
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Slim\Factory\AppFactory;
use DI\Container; // это и есть PHP-DI
 
// подключаем Composer
require 'vendor/autoload.php';
 
// Создание контейнера PHP-DI
$container = new Container();
 
// Создаём объект приложения Slim и передаём в него контейнер
AppFactory::setContainer($container);
$app = AppFactory::create();
 
// указываем базовый путь для роутинга — поскольку это подкаталог
$app->setBasePath('/slim1');
 
$app->addErrorMiddleware(false, false, false);
 
// http://demo/slim1/
$app->get('/', function (ServerRequestInterface $request, ResponseInterface $response, $args) {
    $response->getBody()->write('Home page');
    return $response;
});
 
// http://demo/slim1/hello
$app->get('/hello', function (ServerRequestInterface $request, ResponseInterface $response, $args) {
    $response->getBody()->write('Hello world!');
    return $response;
});
 
// запускаем приложение
$app->run();

Важно здесь то, что контейнер нужно добавлять перед созданием приложения. Перед тем, как создавать $app нужно передать ему контейнер через setContainer(). Почему так? Обратите внимание, что AppFactory — это статические методы. Когда мы вызываем AppFactory::create(), то по сути происходит инстанцирование объекта класса Slim\App:

    public static function create(
        ?ResponseFactoryInterface $responseFactory = null,
        ?ContainerInterface $container = null,
        ?CallableResolverInterface $callableResolver = null,
        ?RouteCollectorInterface $routeCollector = null,
        ?RouteResolverInterface $routeResolver = null,
        ?MiddlewareDispatcherInterface $middlewareDispatcher = null
    ): App {
        static::$responseFactory = $responseFactory ?? static::$responseFactory;
        return new App(
            self::determineResponseFactory(),
            $container ?? static::$container,
            $callableResolver ?? static::$callableResolver,
            $routeCollector ?? static::$routeCollector,
            $routeResolver ?? static::$routeResolver,
            $middlewareDispatcher ?? static::$middlewareDispatcher
        );
    } 

Поскольку AppFactory::create() мы вызываем без параметров, то будут использованы переменные из текущего static-класса. В нашем случае static::$container.

После этого контейнер можно получить из приложения:

$container = $app->getContainer();

Сейчас у нас нет зависимостей для контейнера, поэтому давайте добавим библиотеку для логирования Monolog в виде сервиса. Для этого установим её с помощью Composer:

composer require monolog/monolog

Теперь добавим в начало файла:

use Monolog\Logger;
use Monolog\Handler\StreamHandler;

В после создания $app добавим сервис logger

// Создадим объект для логирования через контейнер
$container->set('logger', function () {
    $log = new Logger('general');
    $log->pushHandler(new StreamHandler(__DIR__ . '/var/log/app.log', Logger::INFO));
 
    return $log;
});

Чтобы логи сохранялись создадим каталог «var/log» — в нём Monolog будет хранить файлы логов.

Добавим для примера логирование главной страницы:

// http://demo/slim1/
$app->get('/', function (ServerRequestInterface $request, ResponseInterface $response, $args) use ($app) {
    
    $container = $app->getContainer();
 
    // если установлен лог, то пишем в него сообщение об ошибке
    if ($container->has('logger'))
        $container->get('logger')->info('Home');
    
    $response->getBody()->write('Home page');
    
    return $response;
});

Чтобы получить доступ к $app внутри функции, мы используем use. На практике, скорее всего так делать не придётся, поскольку при работе с php-классами для роутинга, Slim автоматически будет передавать контейнер в конструктор. Это мы рассмотрим позже.

Сейчас главное понять общий принцип работы контейнера. Вначале нужно убедиться, что сервис logger существует, а потом его использовать как это определено в его классе Monolog.

Если вы посмотрите на файл var/log/app.log, то увидите, что в нём стали появляться новые строчки.

Использование контейнера, а точнее его сервисов, делает приложение немного проще. После мы сможем выделить создание сервисов отдельным файлом — так будет удобней ими управлять.

Стоит отметить ещё один подход к проектированию приложения. Мы создали контейнер и потом, если нужно, получаем его из объекта $app. Но можно поступить наоборот: создать контейнер, потом создать в нем сервис app и потом уже получить его из контейнера для работы. То есть главным становится уже контейнер.

Приведу полный рабочий код, чтобы было понятно:

<?php
 
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Slim\Factory\AppFactory;
use DI\Container;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
 
// подключаем Composer
require 'vendor/autoload.php';
 
// Создание контейнера
$container = new Container();
 
// создаем сервис app — основной объект приложения
$container->set('app', function () {
    return AppFactory::create();
});
 
// получаем из контейнера и работаем как обычно
$app = $container->get('app');
 
// указываем базовый путь для роутинга — поскольку это подкаталог
$app->setBasePath('/slim1');
 
// отлавливаем ошибки
$app->addErrorMiddleware(false, false, false);
  
// Создадим объект для логирования через контейнер
$container->set('logger', function () {
    $log = new Logger('general');
    $log->pushHandler(new StreamHandler(__DIR__ . '/var/log/app.log', Logger::INFO));
 
    return $log;
});
 
// http://demo/slim1/
// обратите внимание, что здесь уже применяется use $container
$app->get('/', function (ServerRequestInterface $request, ResponseInterface $response, $args) use ($container) {
    
    // если установлен лог, то пишем в него сообщение об ошибке
    if ($container->has('logger'))
        $container->get('logger')->info('Home');
    
    // если нужно, то можно получить $app из контейнера
    // $app = $container->get('app');
    // var_dump($app->getContainer()); // null
    
    $response->getBody()->write('Home page');
    
    return $response;
});
 
// http://demo/slim1/hello
$app->get('/hello', function (ServerRequestInterface $request, ResponseInterface $response, $args) {
    $response->getBody()->write('Hello world!');
    return $response;
});
 
// запускаем приложение
$app->run();
 
# end of file

Поскольку $app у нас хранится как сервис app, то мы можем получить его в любом месте. Но при этом $app уже не управляет контейнером. Если выполнить:

$app = $container->get('app');
var_dump($app->getContainer());

То получим null.

И хотя такой подход часто встречается, на мой взгляд всё-таки удобней пользоваться $app, в который внедрён контейнер. Тем более, что в Slim контейнер может автоматически передаваться в конструктор Action, а обработчик Middleware также может получить нужный $app. Поэтому потребность прятать приложение в контейнер крайне мала.

И еще раз возвращаясь к логированию. Мы добавили лог к главной странице, явно прописав его в самом роуте. Как вы понимаете, для этого несколько удобней использовать Middleware.

// не забудьте добавить
// use Psr\Http\Server\RequestHandlerInterface; 
 
...
// логирование всех запросов
$logMiddleware = function (ServerRequestInterface $request, RequestHandlerInterface $handler) use ($app) {
    
    $container = $app->getContainer();
  
    // если установлен лог, то пишем в него текущий url-путь
    if ($container->has('logger'))
        $container->get('logger')->info($request->getUri()->getPath());
    
    $response = $handler->handle($request);
    
    return $response;
};
 
// добавим его в приложение
$app->add($logMiddleware);
...

Теперь вместо того, чтобы менять роуты, достаточно использовать $logMiddleware.

Структура приложения

Теперь мы уже знакомы с основами Slim, поэтому можно расширить наше приложение. Пока у нас всё в одном файле index.php, что не совсем правильно. Нам нужно придумать структуру каталогов, где мы будем хранить наши файлы. Тем более, что нам предстоит ещё разобраться как перейти на ОПП вместо функций роутинга и Middleware.

Если рассматривать типовые варианты именования каталогов, то обычно выделяют следующие.

  • Каталог vendor — это каталог для Composer. Мы его не меняем и всегда используем его автозагрузку файлов (PSR-4).
  • Каталог var — это каталог для изменяемых файлов. Это логи, кэш, может быть файлы SQLite.
  • Каталог app — каталог пользовательского приложения. Именно здесь размещаются файлы конфигурации и прочих модулей.
  • Каталог src — это каталог, где содержится «ядро» создаваемого приложения/фреймворка/CMS. Например если вы делаете публичный проект, то этот каталог может обновляться «накатом» — здесь не должно быть пользовательских файлов. Часто вместо src используют system/library/core и т.п.
  • Каталог resources — предназначен для прочих файлов проекта.

Отдельно стоит отметить каталог public (иногда web). Обычно он указывает на web-каталог сервера public_html. То есть в этом каталоге размещаются только те файлы, доступ к которым возможен через браузер. Все остальные файлы размещаются на более высоком уровне сервера и недоступны извне. Такая схема хороша, но только если у вас не виртуальный хостинг.

Поскольку в 99% случаев сайты используют именно вирутальный хостинг, то каталог public не используется — всё располагается в public_html вашего сервера. Для закрытия доступа используется .htaccess с директивой Deny from all, поэтому особых проблем с безопасностью не будет. Ну и плюс не стоит использовать открытые файлы, вроде .env для конфигураций и хранения секретных данных. Вместо этого нужно использовать обычные php-файлы с массивами. Тогда даже прямой вызов файла никаких проблем не вызывает.

Поэтому мы будем строить так, чтобы приложение можно было использовать на виртуальном хостинге, но при желании, можно будет его перенести на любой уровень выше. Здесь вопрос чисто технический — достаточно определить базовый константы.

Что у итоге получается? Пусть у нас будет такая структура:

app/
    Config/
    Modules/
    Middleware/
    bootstrap.php
  
src/
    Core/
        functions.php
    Debug/
        Pr.php
 
var/
    cache/
    log/
    sqlite/
    
.htaccess
index.php
composer.json

Файл index.php — фронт-контролёр. В его задачу входит создание базовых констант (это зависит от расположения файла) и передача управления в app/bootstrap.php.

В файле bootstrap.php формируется и запускается приложение Slim.

Дополнительные зависимости будут храниться в каталоге Config.

Каталог Modules будет хранить модули приложения. Мы говорили о концепции MVC и ADR, но вопрос лишь в том, как именно должны разделяться файлы одного модуля. Обычно принято хранить контролёры отдельно от моделей и представлений. Лично мне такой подход не нравится: я предпочитаю файлы модуля держать в одном месте. А какая там будет использоваться концепция — уже значения не имеет. Ну и кроме того, мы всего лишь знакомимся с Slim, поэтому большой разницы в этом разрезе не будет.

Каталог Middleware будет хранить классы Middleware.

Каталог src для хранения дополнительного функционала. Мы используем Composer, но часть кода придётся написать вручную. Поэтому здесь будут храниться функции и какие-то дополнительные классы.

Давайте начнём с чистого листа. Пусть у нас будет проект http://demo/slim2.

Вы можете сразу обратиться к итоговому коду, который я опубликовал на GitHub (github.com/maxsite/Slim-Skeleton). По ходу статьи я буду показывать откуда и что получилось.

Файл composer.json будет таким:

{
    "require": {
        "slim/slim": "4.*",
        "slim/psr7": "^1.5",
        "php-di/php-di": "^6.3",
        "monolog/monolog": "^2.3"
    },
    "autoload": {
        "psr-4": { 
			"App\\": "app/",
			"Lib\\": "src/"
		}
    }
} 

Здесь мы подключаем зависимости, а также определяем автозагрузку PSR-4. То есть namespace App будет ссылаться на каталог app, а Lib на src. Если вы захотите поменять расположение каталогов, то достаточно это будет сделать в этом файле и потом обновиться через Composer.

Теперь запускаем:

composer update

Появится каталог vendor. Зависимости установлены.

Файл index.php сделаем таким:

<?php
 
define('BASE_DIR', dirname(realpath(__FILE__)) . DIRECTORY_SEPARATOR);
define('SLIM_APP_BASEPATH', '/' . basename(__DIR__));
 
require BASE_DIR . 'app/bootstrap.php';
 
# end of file

Здесь мы определяем каталог BASE_DIR. Это нужно для того, чтобы зафиксировать корневой каталог приложения и все остальные файлы можно подключать относительно него. В теории можно ещё переопределить и app, var, но это уже экономия на спичках.

Константа SLIM_APP_BASEPATH нам нужна для того, чтобы использовать в setBasePath(). Раньше мы её определяли вручную, теперь используем на основе текущего каталога. Если у вас какое-то особое расположение сайта, то можно указать в этом файле, а не трогать bootstrap.php.

Теперь давайте разбираться с bootstrap.php. Мы уж знаем, что перед запуском приложения Slim необходимо выполнить несколько задач. Мы их выделим в отдельные файлы и сохраним в каталоге Config.

Первое что нужно выделить отдельно — это создание зависимостей для контейнера. Пусть это будет app/Config/container.php, где мы разместим код создания логирования.

<?php
 
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
 
// Создадим объект для логирования через контейнер
// https://github.com/Seldaek/monolog
$container->set('logger', function () {
    $log = new Logger('general');
    $log->pushHandler(new StreamHandler(BASE_DIR . 'var/log/app.log', Logger::INFO));
 
    return $log;
});
 
# end of file

После контейнера мы можем выделить какие-то настройки в app/Config/settings.php. Пока мы оставим файл пустым, но в теории в нем можно будет хранить настройки всего приложения. А поскольку их можно будет держать в контейнере, то подключаем после container.php.

Потом у нас пойдут Middleware в файле app/Config/middleware.php. Пока он пусть будет пустым.

Также у нас будет ещё один особый Middleware для отлова ошибок (мы используем его для метода addErrorMiddleware). Пусть это будет файл app/Config/errorMiddleware.php.

<?php
 
$app->addErrorMiddleware(false, false, false);
 
# end of file

После этого нам потребуется файл для задания маршрутов. Выделим для этого файл app/Config/routes.php и разместим там наши адреса:

<?php
 
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
 
// http://demo/slim2/
$app->get('/', function (ServerRequestInterface $request, ResponseInterface $response, $args) {
    $response->getBody()->write('Home page');
    return $response;
});
 
// http://demo/slim2/hello
$app->get('/hello', function (ServerRequestInterface $request, ResponseInterface $response, $args) {
    $response->getBody()->write('Hello world!');
    return $response;
});
 
# end of file

Но это ещё не всё. В самом начале нам нужно подключить src/Core/functions.php — это какие-то функции, которые могут использоваться на уровне всего приложения. Как правило всегда есть небольшие хелперы, которые использует разработчик. Например я использую функцию pr(), которая есть в MaxSite CMS и Albireo для отладки кода (это обертка для print_r).

Вот что у нас получилось в итоге:

<?php
 
use Slim\Factory\AppFactory;
use DI\Container;
 
require BASE_DIR . 'vendor/autoload.php';
require BASE_DIR . 'src/Core/functions.php';
 
$container = new Container();
 
AppFactory::setContainer($container);
$app = AppFactory::create();
 
if (SLIM_APP_BASEPATH) $app->setBasePath(SLIM_APP_BASEPATH);
 
require BASE_DIR . 'app/Config/container.php';
require BASE_DIR . 'app/Config/settings.php';
require BASE_DIR . 'app/Config/middleware.php';
require BASE_DIR . 'app/Config/errorMiddleware.php';
require BASE_DIR . 'app/Config/routes.php';
 
$app->run();

Попробуйте посмотреть сайт в браузере — всё должно работать. Теперь работать с приложением будет удобней.

Модули для роутинга. Отлов 404-ошибки

Мы пока работали с callback-функциями, а по хорошему нужно всё делать через php-классы. Для начала разберёмся с тем, как работать роутингом. Например мы хотим оформить главную страницу в виде обычного html-файла.

Для этого нам нужно привязать в роутинге адрес к определенному классу. Откроем файл routes.php и вместо существующего кода для главной пропишем новый класс.

$app->get('/', '\App\Modules\Home\Home:home');

Эту строчка указывает на то, чтобы Slim передал управление классу \App\Modules\Home\Home с методом home(). Поскольку у нас PSR-4, то это будет соответствовать файлу app/Modules/Home/Home.php

<?php
 
/**
 * Вывод главной страницы
 */
 
namespace App\Modules\Home;
 
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Container\ContainerInterface;
 
class Home
{
    private $container;
 
    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }
 
    public function home(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
    {
        // простой шаблонизатор
        $data = ['name' => 'Вася'];
        $body = getTmpl(__DIR__ . '/home.template.php', $data);
 
        // формируем ответ
        $response->getBody()->write($body);
 
        return $response;
    }
}
 
# end of file

Рассмотрим этот код подробнее. Вначале указывается namespace, который совпадает с каталогом расположения файла. так работает PSR-4. Дальше секция use в которой перечисляются используемые типы данных.

Сам класс состоит из конструктора, который принимает контейнер приложения. Я об этом уже упоминал, поэтому если нужно, контейнер будет сразу доступен в нашем классе.

Метод home() по сути и есть наша исходная callback-функция. Она имеет те же самые входные и выходные параметры. Но вместо простого текста, мы передаём в тело http-ответа содержимое файла шаблона home.template.php:

<!DOCTYPE html>
<html>
<h1>Hello, <?= $name ?>!</h1>
<h2>This is Slim4</h2>
</html>

Для шаблонизатора используется core-функция getTmpl(), которая принимает имя файла и массив данных. Массив будет автоматом развёрнут в обычные php-переменные: в нашем примере в шаблоне появится переменная $name.

После обновления главная страница выведет:

Hello, Вася!
This is Slim4

Подобный подход часто используется в MVC. Здесь $data — это то, что будет получено из Модели, а $body сформирован из View. Если вам нравится такой вариант, то вы легко сможете добавить нужные классы.

Очень часто класс контролёра MVC содержит сразу несколько методов. Например контролёр работы с формой может содержать кучу методов. Таким образом в роутинге нужно будет прописывать что-то вроде такого:

$app->get('/form', '\App\Modules\Form\Controller:show');
$app->post('/form', '\App\Modules\Form\Controller:create');

То есть здесь указывается один и тот же контролёр, но меняются его методы.

В качестве альтернативы можно использовать модель Action Domain Responder (ADR), где в основе лежит Action (действие). Action также представляет собой отдельный класс, но только с методом __invoke(). Это магический php-метод, который позволяет использовать класс как функцию. Таким образом меняется и задание роутинга, и сам класс.

<?php
 
/**
 * Вывод главной страницы
 */
 
namespace App\Modules\Home;
 
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Container\ContainerInterface;
 
class Home
{
    private $container;
 
    // constructor receives container instance
    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }
 
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
    {
        // простой шаблонизатор
        $data = ['name' => 'Вася'];
        $body = getTmpl(__DIR__ . '/home.template.php', $data);
 
        // формируем ответ
        $response->getBody()->write($body);
 
        return $response;
    }
}

Роутинг же задается так:

$app->get('/', \App\Modules\Home\Home::class);

Результат будет точно таким же, но с __invoke() можно не думать над именем метода.

Что касается ADR, то в ней принимается то, что Action должен описывать только одно единственное действие. В случае с «классическим» MVC, контролёр обычно содержит сразу много действий. Если следовать ADR, то каждый Action будет отдельным файлом. С одной стороны это увеличивает их количество, а с другой с ними легче работать. Кроме того, такие одиночные файлы (Action) могут использоваться повторно, поскольку не содержат зависимостей от других Action.

Какой вариант выбрать зависит только от вас. Slim не накладывает ограничений на структуру приложений.

Теперь давайте я покажу как можно перехватить 404-ошибку и вместо сообщения Slim выводить свой вариант, основанный на html-шаблоне.

Для начала откроем файл errorMiddleware.php и сделаем его таким:

<?php
 
use Psr\Http\Message\ServerRequestInterface;
 
$customErrorHandler = function (
    ServerRequestInterface $request,
    Throwable $exception
) use ($app) {
    return \App\Modules\Page404\Page404::response($app, $exception, $request);
};
  
if ($container->has('logger'))
    $errorMiddleware = $app->addErrorMiddleware(true, true, true, $container->get('logger'));
else
    $errorMiddleware = $app->addErrorMiddleware(true, true, true);
 
$errorMiddleware->setDefaultErrorHandler($customErrorHandler);
 
# end of file

Здесь мы регистрируем для приложения свой обработчик ошибочных Middleware. Сам по себе обработчик будет возвращать наш класс \App\Modules\Page404\Page404. Метод response() мы делаем статичным, чтобы упростить его вызов (но при желании можно сделать обычным классом). Также мы сразу передаём в приложение сервис logger, проверив что он есть.

Теперь делаем файл app/Modules/Page404/Page404.php:

<?php
 
/**
 * Модуль, который отвечает за вывод 404-ошибки
 */
 
namespace App\Modules\Page404;
 
use Psr\Http\Message\ServerRequestInterface;
use Fig\Http\Message\StatusCodeInterface;
 
class Page404
{
    public static function response($app, \Throwable $exception, ServerRequestInterface $request)
    {
        $container = $app->getContainer();
 
        // если установлен лог, то пишем в него сообщение об ошибке
        if ($container->has('logger'))
            $container->get('logger')->error('404', [$exception->getMessage()]);
 
        // простой шаблонизатор
        $body = getTmpl(__DIR__ . '/404.template.php');
 
        // формируем ответ
        // указываем http-код ошибки 404
        $response = $app->getResponseFactory()->createResponse(StatusCodeInterface::STATUS_NOT_FOUND);
 
        $response->getBody()->write($body);
 
        return $response;
    }
}
 
# end of file

Ну и сам html-шаблон в файле 404.template.php:

<!DOCTYPE html>
<html>
<h1>404</h1>
<h2>Not Found</h2>
</html>

В принципе этот код уже нам знаком, обращу только момент на параметр метода createResponse(), через который мы формируем http-ответ. Здесь указывается статус STATUS_NOT_FOUND, что соответствует коду 404. Если этого не сделать, то Slim будет отдавать обычный код 200, что для ненайденной страницы неверно.

Метод \App\Modules\Page404\Page404::response() может быть совершенно произвольным. Главное, чтобы он возвращал response-объект.

Использование своего обработчика $errorMiddleware один из способов отлова 404-страниц. Другой способ я уже показывал раньше — это указать последним роутер:

$app->get('/{slug}', \App\Modules\свой-404-класс::class);

А потом создать этот класс по аналогии с Home.

Какой вариант использовать — зависит от разработчика.

Добавляем Middleware как PHP-классы

Как работать с Middleware мы уже знаем, поэтому рассмотрим вопрос как их переделать из функций в PHP-классы. У нас есть каталог app/Middleware, где будут храниться классы, а подключать их к приложению нужно будет в app/Config/middleware.php В этом случае они будут работать для всех адресов.

Если же нужно прицепить Middleware к определённому маршруту, то делается это уже в файле роутинга app/Config/routes.php

Для начала создадим файл app/Middleware/AfterMiddleware.php:

<?php
 
/**
 * Пример Middleware
 */
 
namespace App\Middleware;
 
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;
 
class AfterMiddleware
{
    public function __invoke(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
    {
        $response = $handler->handle($request);
        $response->getBody()->write('AFTER');
  
        return $response;
    }
}
 
# end of file

Это пример из справки Slim. Здесь мы также используем __invoke(), чтобы упростить вызов класса. Теперь добавим в файл app/Config/middleware.php

$app->add(\App\Middleware\AfterMiddleware::class);

Теперь на каждой странице будет текстовая приписка «AFTER». Если же нужно добавить Middleware для конкретного маршрута, то указываем это в его роутинге:

$app->get('/', \App\Modules\Home\Home::class)->add(\App\Middleware\AfterMiddleware::class);

По такому принципу строятся все Middleware. Но мы рассмотрим ещё один пример из справки Slim — класс BeforeMiddleware:

<?php
 
/**
 * Пример Middleware
 */
 
namespace App\Middleware;
  
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;
use Psr\Container\ContainerInterface;
 
class BeforeMiddleware
{
    private $container;
    
    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }
 
    public function __invoke(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
    {
        $response = $handler->handle($request);
        $existingContent = (string) $response->getBody();
    
        $response = new Response();
        $response->getBody()->write('BEFORE' . $existingContent);
    
        return $response;
    }
}
 
# end of file

Здесь я добавил конструктор класса, через который можно получить контейнер приложения. Например если нужен сервис logger, то мы берём его из контейнера.

Теперь обратите внимание, как формируется ответ. Вначале получается текущее тело запроса, а потом создаётся новый объект, в который отправляется изменённый текст. Суть в том, что ответ $response можно получить как из handler-обработчика, так и создать самостоятельно новый.

Настройки Settings

Наше приложение уже почти готово, осталось добавить только settings-настройки. Где они могут пригодится? Например можно хранить управление выводом ошибок для addErrorMiddleware(). Скажем для отладки было бы полезным получить полный и красивый отчёт вывода. Логично эти опции вынести в отдельный файл.

Есть несколько подходов к организации настроек сайта, но наиболее привычным способом будет использование в конфигурационном файле обычного php-массива, который возвращается по return. Например файл app/Config/app.php:

return [
    'ErrorMiddleware' => [
        'customErrorHandler' => false, // свой обработчик errorMiddleware
        'displayErrorDetails' => true, // выводить детали ошибки
        'logErrors' => false, // писать в лог
        'logErrorDetails' => false, // детали в лог
    ],
];

Но нюанс в том, что эти данные нам могут понадобиться в разных частях программы, поэтому правильным было бы перенести их в общий контейнер. Второй момент — может получиться так, что нужно будет работать с разными конфигурационными файлами, работающими по этому же принципу (возврат по return). Например, если нужно будет добавить конфигурационный файл для баз данных db.php. Поэтому нужен механизм, позволяющий работать с разными файлами.

Для этого мы будем использовать простой класс \Lib\Settings\Settings, который будет храниться в контейнере, но перед этим он считает все указанные конфигурационные файлы из каталога app/Config.

Таким образом файл app/Config/settings.php получится таким:

<?php
 
// alias => файл конфигурации
$files = [
    'app' => 'app.php',
    'db' => 'db.php',
];
 
// сохраняем в контейнере
$container->set('config', function () use ($files) {
    return new \Lib\Settings\Settings($files);
});
 
# end of file

В массиве $files мы указываем имя файла и его псевдоним/ключ. Дальше сохраняем в контейнере класс Settings. Он в свою очередь сам считает все указанные файлы.

<?php
 
namespace Lib\Settings;
 
class Settings
{
    private $settings = [];
 
    public function __construct(array $files)
    {
        $pathConfig = BASE_DIR . 'app/Config/';
 
        foreach ($files as $alias => $file) {
            if (file_exists($pathConfig . $file)) {
                $this->settings[$alias] = require $pathConfig . $file;
            }
        }
    }
 
    public function getAlias(string $alias, $default = [])
    {
        return $this->settings[$alias] ?? $default;
    }
}
 
# end of file

Для получения массива из файла конфигурации, вначале нужно получить экземпляр объекта Settings из контейнера, а потом выполнить метод getAlias().

$confApp = $container->get('config')->getAlias('app');
 
pr($confApp);
 
Array
(
    [ErrorMiddleware] => Array
        (
            [customErrorHandler] => 
            [displayErrorDetails] => 1
            [logErrors] => 
            [logErrorDetails] => 
        )
)

Для примера используем эту конфигурацию в errorMiddleware.php. Я добавил немного логики, чтобы более гибко управлять 404-ошибками.

<?php
 
use Psr\Http\Message\ServerRequestInterface;
 
// получаем данные из контейнера
$confApp = $container->get('config')->getAlias('app');
 
// пошли опции
$customErrorHandler = $confApp['ErrorMiddleware']['customErrorHandler'] ?? true;
$displayErrorDetails = $confApp['ErrorMiddleware']['displayErrorDetails'] ?? false;
$logErrors = $confApp['ErrorMiddleware']['logErrors'] ?? false;
$logErrorDetails = $confApp['ErrorMiddleware']['logErrorDetails'] ?? false;
 
if ($customErrorHandler) {
    // Define Custom Error Handler
    $customErrorHandler = function (
        ServerRequestInterface $request,
        Throwable $exception
    ) use ($app) {
        return \App\Modules\Page404\Page404::response($app, $exception, $request);
    };
 
    // Add Error Middleware
    if ($container->has('logger'))
        $errorMiddleware = $app->addErrorMiddleware($displayErrorDetails, $logErrors, $logErrorDetails, $container->get('logger'));
    else
        $errorMiddleware = $app->addErrorMiddleware($displayErrorDetails, $logErrors, $logErrorDetails);
 
    $errorMiddleware->setDefaultErrorHandler($customErrorHandler);
} else {
    // Slim вариант вывода ошибок
    if ($container->has('logger'))
        $app->addErrorMiddleware($displayErrorDetails, $logErrors, $logErrorDetails, $container->get('logger'));
    else
        $app->addErrorMiddleware($displayErrorDetails, $logErrors, $logErrorDetails);
}
 
# end of file

Дальше просто. Если нужно добавить новый конфигурационный файл, то достаточно его указать в app/Config/settings.php. После он автоматом станет доступным через контейнер.

Работа с модулями приложения

В целом каркас приложения уже готов, но мы добавим в него ещё несколько штрихов, чтобы сделать работу с модулями более удобной. Лично я привык именно к модульной структуре, когда все файлы модуля расположены в одном месте. С практической точки зрения это намного удобней, чем раскидывать файлы по разным частям приложения.

Работать с такими модулями удобно ещё и потому что они по сути автономны. Если модуль не нужен, то достаточно его просто удалить. Но вот чтобы подключить модуль нам нужно предусмотреть механизм, который будет автоматически подключать файлы модуля к основному приложению. Например модуль имеет свой роутинг. Если он фиксированный, то было бы здорово сделать так, чтобы он автоматом подключался к основному routes.php.

C другой стороны у модуля могут быть и другие задачи, например работа с контейнером или регистрация своих php-классов. То есть мы не знаем наверняка что именно будет делать модуль, но он должен получить доступ к $app до его запуска.

Поэтому поступим хитро. Подключим в bootstrap.php файл app/Config/initModules.php

...
require BASE_DIR . 'app/Config/errorMiddleware.php';
require BASE_DIR . 'app/Config/initModules.php';
require BASE_DIR . 'app/Config/routes.php';
...

Сам же initModules.php будет автоматом искать файлы init.php в каждом модуле и подключать как обычно.

<?php
 
$allFiles = glob(BASE_DIR . 'app/Modules/*/init.php');
 
foreach ($allFiles as $file) {
    require $file;
}
 
# end of file

Таким образом, у разработчика модуля будет возможность с помощью файла init.php добавить свои роуты.

Для примера сделаем модуль Form, где рассмотрим работу с обычной формой. Создадим каталог app/Modules/Form и в нём несколько файлов.

Роутинг пропишем в init.php

// http://demo/slim2/form
$app->get('/form', \App\Modules\Form\FormShow::class);
$app->post('/form', \App\Modules\Form\FormPost::class);

Форма будет иметь два состояния: отображение самой формы и приём данных. Отображение формы будет формироваться в FormShow.php. Приём будет идти в FormPost.php с одноименными классами. Я буду следовать не MVC, а ADR, поэтому в классах буду использовать метод __invoke().

В качестве файла с html-кодом формы будем использовать show.template.php:

<!DOCTYPE html>
<html>
<h1>Sample form</h1>

<?= $message ?>

<div>
    <form method="POST">
        <div><label>Name: <input type="text" name="name"></label></div>
        <div><label>Email: <input type="text" name="email"></label></div>
        <div><button type="submit">Send</button></div>
    </form>
</div>

</html>

Поскольку это демо-пример, то разметка примитивная.

Теперь сделаем FormShow.php, который будет просто выводить эту форму:

<?php
 
/**
 * Вывод формы
 */
 
namespace App\Modules\Form;
 
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
 
class FormShow
{
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
    {
        $body = getTmpl(__DIR__ . '/show.template.php', ['message' => '']);
        $response->getBody()->write($body);
 
        return $response;
    }
}
 
# end of file

Переменная $message будет содержать результат отправки формы. При начальном отображении там пусто.

Блок отображения также сделаем в виде шаблона message.template.php.

<div>
    <div><b>Name:</b> <?= htmlspecialchars($name) ?></div>
    <div><b>Email:</b> <?= htmlspecialchars($email) ?></div>
</div>

В нём будут выводиться данные формы.

Теперь класс обработчика POST-запроса. Файл FormPost.php

<?php
 
/**
 * Получение post от формы
 */
 
namespace App\Modules\Form;
 
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
 
class FormPost
{   
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
    {
        // получить данные из формы
        $parsedBody = $request->getParsedBody();
  
        $message = getTmpl(__DIR__ . '/message.template.php', [
            'name' => $parsedBody['name'],
            'email' => $parsedBody['email'],        
        ]);
  
        $body = getTmpl(__DIR__ . '/show.template.php', ['message' => $message]);
  
        $response->getBody()->write($body);
  
        return $response;
    }
}
 
# end of file

Данные от формы получаем в запросе через метод getParsedBody(). На выходе обычный массив, как если бы мы работало через $_POST. Дальше формируем блок сообщения $message с помощью шаблона message.template.php. В него мы и передаём данные формы. А уже после ещё раз выводим форму, где и выводится блок message.

Логика примера достаточно простая, я специально не стал её усложнять, чтобы можно было легко разобраться. На практике же нужно ещё добавить валидацию входящих данных, потом какие-то действие, поскольку форма нужна для каких-то задач (например отправка на email).

Несколько слов о шаблонах.

Сейчас это очень простые файлы, которые принимают готовые php-переменные. По этому принципу работают практически все существующие шаблонизаторы. Разница только в «синтаксическом сахаре» и дополнительных функциях. Поэтому не важно какой именно будет шаблонизатор использоваться в вашем проекте, суть будет одна — указать имя шаблона и массив данных.

Но есть ещё один момент, на который нужно обратить внимание. Представьте себе, что вы создаёте «движок», а не готовый сайт. Поэтому html-разметка будет отличаться от вашего исходного варианта. Почти все современные php-фреймворки рассчитаны только на одну разметку, поэтому у конечного пользователя нет другой возможности, кроме как менять шаблон в самом модуле или каком-то выделенном каталоге (например templates). То есть вопрос, что фреймворк будет поддерживать шаблоны сайта, как это сделано в любой CMS, обычно разработчиками не ставится.

Сложность здесь в том, что файл шаблона (например show.template.php) имеет довольно жесткую привязку заданному каталогу. Таким образом, единственный правильный способ — это сделать так, чтобы модуль брал сам на себя проверку пользовательского файла шаблона. Примерно так:

    ...
    
    $fileTmpl = '/show.template.php';
    
    if (file_exists(BASE_DIR . 'resources/views/Modules/Form' . $fileTmpl))
        $file = BASE_DIR . 'resources/views/Modules/Form' . $fileTmpl;
    else
        $file = __DIR__ .  $fileTmpl;
 
    $body = getTmpl($file, ['message' => '']);
    ...

Либо можно воспользоваться небольшой функцией-хелпером findFileTmpl(), которая возвращает имя файла либо в пользовательском каталоге (resources/views/Modules/Form), либо в каталоге модуля (app/Modules/Form).

    ...
    
    $file = findFileViews('Modules/Form', 'show.template.php'); // каталог модуля, файл
    $body = getTmpl($file, ['message' => '']);
    ...

То есть модуль позволяет разместить файл шаблона в любом другом предопределенном каталоге. Если «движок» предполагает разные шаблоны сайта (как в CMS), то выбранный каталог может указываться в общей конфигурации. Таким образом, если у пользователя возникнет задача изменить вёрстку формы, то ему не нужно будет переписывать файлы модуля.

Ну и плюс такого подхода в том, что мы можем размещать файлы абсолютно произвольно, что редко встречается в других php-фрейморках.

В любом случае вопрос о шаблонизации сайта несколько выходит за рамки статьи, и этот вопрос отдан на откуп разработчику.

Итого

Slim 4 хорош для проектов, где использование больших фреймворков не оправдано. Главная «фишка» Slim — это его поддержка стандартов PSR, что позволяет без особых сложностей подключать и использовать сторониe PSR-библиотеки к своему приложению.

Итоговый код вы можете скачать с моего репозитория на GitHub: github.com/maxsite/Slim-Skeleton.

Похожие записи
Комментарии (17) RSS
1 Артём 2022-01-14 10:56:21

Что делать?

Type: TypeError

Message: App\Middleware\BeforeMiddleware::__invoke(): Argument #2 ($handler) must be of type Psr\Http\Server\RequestHandlerInterface, Slim\Http\Response given


2 Admin 2022-01-14 10:59:58 admin

Наверное не подключили slim/psr7 через композер.


3 Артём 2022-01-14 13:01:37

Добавил slim/psr7, не помогло.

Я пытаюсь в Middleware вызвать контейнер. Пока ни как.


4 Admin 2022-01-14 13:05:06 admin

Контейнер автоматом можно получить в конструкторе Middleware. Посмотрите пример с BeforeMiddleware.


5 Артём 2022-01-14 13:13:40

Так и делаю, использую ваш пример.

Но я начал уже делать, и контейнер у меня иначе вызывается

$settings = require __DIR__ . '/Settings.php';
$app = new \Slim\App($settings);
$app->add(new \CorsSlim\CorsSlim());
$container = $app->getContainer();

И добавляю вот так

$container['logger'] = function($c) 
{
    $logger = new \Monolog\Logger('API_logger');
    $file_handler = new \Monolog\Handler\StreamHandler('../logs/app.log');
    $logger->pushHandler($file_handler);
    return $logger;
};

Вызов в роуте работает: $this->has('logger'), в мидлвеар - нет.


6 Admin 2022-01-14 13:20:13 admin

Лучше использовать фабрику AppFactory, а потом добавить в неё контейнер через setContainer. Так проще. Если же вы самостоятельно инстанцируете App, то там нужно соблюдать порядок аргументов. Посмотрите конструктор этого класса, увидите, что контейнер передаётся вторым параметром.


7 Артём 2022-01-14 13:21:31
Посмотрите конструктор этого класса, увидите, что контейнер передаётся вторым параметром.

Спасибо!

Простите, какого?


8 Admin 2022-01-14 13:22:37 admin

Класс App: vendor\slim\slim\Slim\App.php


9 Артём 2022-01-14 13:29:25

Так что не правильно делаю? (

$settings = require __DIR__ . '/Settings.php';
$app = new \Slim\App($settings);
$app->add(new \CorsSlim\CorsSlim());
$container = $app->getContainer();
require __DIR__ . '/Dependencies.php';

В Dependencies.php уже

$container['logger'] = function($c) {
   $logger = new \Monolog\Logger('API_logger');
    $file_handler = new \Monolog\Handler\StreamHandler('../logs/app.log');
    $logger->pushHandler($file_handler);
    return $logger;
};

В роутах работает, через $this

Подскажите пожалуйста, как в мидлвеар прокинуть или вызвать корректно контейнер, в моём случае.

Спасибо!


10 Admin 2022-01-14 13:32:13 admin

Проблема в этой строчке:

$app = new \Slim\App($settings);

Просто используйте вместо этого:

$container = new Container();
AppFactory::setContainer($container);
$app = AppFactory::create();

11 Артём 2022-01-14 13:39:52
Просто используйте вместо этого:
Fatal error: Uncaught Error: Class "Slim\Factory\AppFactory" not found in

use Slim\Factory\AppFactory;

Что сделал не так?


12 Admin 2022-01-14 13:42:43 admin

Думаю, что вам лучше взять код с гитхаба, его запустить и уже потом модифицировать. А так вы сразу что-то меняете, но не работают основы. Значит что-то не так делаете. :-)


13 Артём 2022-01-14 13:47:37

Так и сделал, взял. :)

Не знаю можно ли ссылку, если что удалите пожалуйста.

https://github.com/maurobonfietti/rest-api-slim-php

Вот пытался по вашему примеру залогировать в мидлеваре. Пока ни как :(


14 Admin 2022-01-14 13:54:01 admin

Так там про Slim3 речь идёт. :-)


15 Артём 2022-01-14 14:38:02
})->add(new LogMiddleware($container));

Если понадобиться, то вот так заработало.


16 Валерий 2022-03-04 22:43:16

Добрый вечер! Спасибо за статью. Подскажите пожалуйста как правильно запихнуть в обьект response файл отображения index.php без использования шаблонизатора?


17 Admin 2022-03-05 19:21:57 admin

Точно также как и с шаблонизатором. Только без указания массива $data.