Почему я отказался от Laravel и собрал свой шаблон для простых сайтов

от
PHP/MySQL    php, фреймворки, framework, framework-less, router, template engine, шаблонизатор, twig, fastroute

Для своей персональной странички некоторое время я использовал Laravel. Было чувство, что я стреляю из пушки по воробьям, потому что огромный пласт функций этого фреймворка у меня не использовался. На сайте нет форм, подключение к базе данных используется лишь для парочки CRUD'ов, не нужны ни сессии, ни валидаторы, ни логирование, ни ORM.
Поначалу я хотел всё оставить как есть, с "заделом на будущее", но прошло два года, а будущее всё никак не наступало. Тогда-то я и задался вопросом о смене фреймворка на что-то простое.

php-app-diagram.png

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

Содержание


Сторонние компоненты
В качестве роутера можно использовать FastRoute. Шаблонизатор можно взять вообще любой, который нравится. Мой выбор — Twig. Также будет полезен DI контейнер. Опять же выбор не ограничен, но я взял Pimple.

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


Файлы проекта
Предлагаю взять за основу структуру проектов на Laravel с некоторыми упрощениями:
  1. /app/
  2.   - cache/
  3.   - public/
  4.     - dist/
  5.       - css/
  6.       - js/
  7.       - icons/
  8.     - index.php
  9.   - src/
  10.     - Controllers/
  11.     - scss/
  12.     - js/
  13.     - views/
  14.     - config.php
  15.   - vendor/
  16.   - app.php
  17.   - composer.json
  18.   - composer.lock
В cache/ будут храниться кэш шаблонов и прочие временные файлы.
В public/ — статические ресурсы приложения.
В src/ будет php-код и файлы шаблонов. Там же можно хранить исходные файлы стилей, скриптов и даже иконок, а потом генерировать/транспилировать/минифицировать в public/.
В vendor/ как обычно автолоадер и библиотеки.
В app.php настройка (бутстрапинг) приложения и вызов соответствующего контроллера.


Настройка nginx/Apache
Первым делом настроим сервер, чтобы стартовой считалась директория public/.

Nginx:
  1. server {
  2.   listen 80;
  3.   listen [::]:80;
  4.   listen 443 ssl;
  5.  
  6.   root /var/www/app/public;
  7.   index index.php index.html index.htm;
  8.   server_name app.site;
  9.   autoindex off;
  10.  
  11.   location / {
  12.     try_files $uri $uri/ /index.php$is_args$args;
  13.   }
  14.   location ~ \.php$ {
  15.     include snippets/fastcgi-php.conf;
  16.     fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
  17.   }
  18.   location ~* ^.+\.(svg|svgz|eot|otf|woff|jpg|jpeg|gif|png|ico)$ {
  19.     access_log off;
  20.     expires 30d;
  21.   }
  22. }

Apache:
  1. <VirtualHost *:80>
  2.   ServerName app.site
  3.   ServerAdmin admin@app.site
  4.   DocumentRoot /var/www/app/public
  5.  
  6.   <Directory /var/www/app>
  7.     Options Indexes FollowSymLinks MultiViews
  8.     AllowOverride None
  9.     Require all granted
  10.   </Directory>
  11. </VirtualHost>

Теперь создадим public/index.php:
  1. <?php
  2. require_once __DIR__ . '/../app.php';
Он будет вызывать app.php, в котором будет бутстрапинг и настройка роутера. Но пока что можно просто проверить, что сервер правильно перенаправляет все запросы:
  1. <?php
  2. echo 'It works!!!';
Если всё сделано верно, то при переходе на любой адрес сайта app.site будет выведено сообщение "It works!!!".


Установка зависимостей
Устанавливаем все три зависимости при помощи composer:
  1. composer require nikic/fast-route
  2. composer require pimple/pimple ~3.0
  3. composer require twig/twig ^3.0

composer.json:
  1. {
  2.   "require": {
  3.     "nikic/fast-route": "^1.3",
  4.     "pimple/pimple": "~3.0",
  5.     "twig/twig": "3.0"
  6.   }
  7. }

shot-20201124T121702.png shot-20201124T121722.png shot-20201124T121735.png


Бутстрапинг
Теперь подгрузим конфигурацию, настроим автолоадер, подключение к базе данных и включим отображение ошибок для режима отладки.

Начнём с конфигурации src/config.php:
  1. <?php
  2. return [
  3.     'metadata' => [
  4.         'name' => 'App Site',
  5.         'message' => 'It Works!!!',
  6.         'copyright' => '© aNNiMON 2020'
  7.     ],
  8.     'database' => [
  9.         'host' => '127.0.0.1',
  10.         'user' => 'root',
  11.         'pass' => '',
  12.         'name' => 'app'
  13.     ],
  14.     'debug' => true
  15. ];

Теперь настроим автолоадер, прочитаем конфиг и настроим подключение к базе данных:
  1. <?php
  2. use Pimple\Container;
  3.  
  4. const SRC_DIR = __DIR__ . '/src/';
  5.  
  6. // Autoloader
  7. require 'vendor/autoload.php';
  8. spl_autoload_register(function ($class) {
  9.     include SRC_DIR .  str_replace('\\', DIRECTORY_SEPARATOR, $class) . '.php';
  10. });
  11.  
  12. // Container
  13. $container = new Container();
  14. $container['config'] = require SRC_DIR . 'config.php';
  15. $container['db'] = function($c) {
  16.     $db = $c['config']['database'];
  17.     $url = 'mysql:host=' . $db['host'] . ';dbname=' . $db['name'] . ';charset=utf8mb4';
  18.     return new PDO($url, $db['user'], $db['pass'], [
  19.         PDO::ATTR_ERRMODE => PDO::ERRMODE_WARNING,
  20.         PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
  21.     ]);
  22. };
  23.  
  24. if ($container['config']['debug']) {
  25.     error_reporting(E_ALL);
  26.     ini_set('display_errors', '1');
  27. }

База данных будет инициализироваться только при первом обращении к $container['db']. Для теста можно вывести данные из конфига и из БД:
  1. $meta = $container['config']['metadata'];
  2. echo '<b>Name</b>: ' . $meta['name'];
  3. echo '<br/><b>Message</b>: ' .$meta['message'];
  4. echo '<br/><b>Copyright</b>: ' .$meta['copyright'];
  5. $user = $container['db']
  6.     ->query('SELECT * FROM users WHERE id = 1')
  7.     ->fetch();
  8. echo '<br/><b>Username</b>: ' .$user['name'];

shot-20201124T142314.png


Шаблонизатор
Twig настраивается очень легко, достаточно указать директорию с шаблонами, место для кэширования (не забудьте создать папку cache/views) и пробросить глобальный контейнер в шаблонизатор под глобальной переменной app.
  1. use Twig\Environment;
  2. use Twig\Extension\DebugExtension;
  3. use Twig\Loader\FilesystemLoader;
  4.  
  5. $container['twig'] = function ($c) {
  6.     $loader = new FilesystemLoader(SRC_DIR . 'views');
  7.     $twig = new Environment($loader, [
  8.         'cache' => __DIR__ . '/cache/views',
  9.         'auto_reload' => true,
  10.         'debug' => $c['config']['debug'],
  11.     ]);
  12.     $twig->addGlobal('app', $c);
  13.     if ($c['config']['debug']) {
  14.         $twig->addExtension(new DebugExtension());
  15.     }
  16.     return $twig;
  17. };

Поскольку в конфигурации у нас есть возможность включать режим отладки, то можно добавить в Twig соответствующее расширение.

Теперь проверим работу шаблонизатора. Переносим вывод тестовых данных в шаблон src/views/index.twig:
  1. {% set meta = app.config.metadata %}
  2. <b>Name</b> {{ meta.name }}<br/>
  3. <b>Message</b>: {{ meta.message }}<br/>
  4. <b>Copyright</b>: {{ meta.copyright }}<br/>
  5. <b>Username</b>: {{ user.name }}<br/>
  6. <b>User</b>: <pre>{{ dump(user) }}</pre>

Заполняем шаблон данными и рендерим:
  1. $user = $container['db']
  2.     ->query('SELECT * FROM users WHERE id = 1')
  3.     ->fetch();
  4. echo $container['twig']->render('index.twig', ['user' => $user]);

shot-20201124T151233.png


Роутер
Наконец, настраиваем роутер. Добавим главную страницу и страницу о сайте. Также обработаем ситуацию, когда роут не найден, для этого покажем страницу 404.twig.
  1. use Controllers\AbstractController;
  2. use Controllers\AppController;
  3. use FastRoute\Dispatcher;
  4. use FastRoute\RouteCollector;
  5.  
  6. $dispatcher = \FastRoute\simpleDispatcher(function(RouteCollector $r) {
  7.     $r->addRoute('GET', '/', [AppController::class, 'index']);
  8.     $r->addRoute('GET', '/about[/]', [AppController::class, 'about']);
  9. });
  10. $uri = $_SERVER['REQUEST_URI'];
  11. $pos = strpos($uri, '?');
  12. if ($pos !== false) {
  13.     $uri = substr($uri, 0, $pos);
  14. }
  15. $uri = rawurldecode($uri);
  16. $routeInfo = $dispatcher->dispatch($_SERVER['REQUEST_METHOD'], $uri);
  17. switch ($routeInfo[0]) {
  18.     case Dispatcher::NOT_FOUND:
  19.         http_response_code(404);
  20.         echo $container['twig']->render('404.twig');
  21.         break;
  22.     case Dispatcher::FOUND:
  23.         [$class, $method] = $routeInfo[1];
  24.         /** @var AbstractController $instance */
  25.         $instance = new $class;
  26.         $instance->setContainer($container);
  27.         call_user_func_array([$instance, $method], [$routeInfo[2]]);
  28.         break;
  29. }

Полезно иметь базовый контроллер, на случай, если их понадобится несколько. src/Controllers/AbstractController.php:
  1. <?php
  2.  
  3. namespace Controllers;
  4.  
  5. use Pimple\Container;
  6. use Twig\Environment;
  7.  
  8. class AbstractController {
  9.  
  10.     protected Container $container;
  11.  
  12.     public function setContainer(Container $container) : void {
  13.         $this->container = $container;
  14.     }
  15.  
  16.     protected function db() : PDO {
  17.         return $this->container['db'];
  18.     }
  19.  
  20.     protected function twig() : Environment {
  21.         return $this->container['twig'];
  22.     }
  23.  
  24.     protected function render(string $view, array $data = []) : void {
  25.         echo $this->twig()->render($view, $data);
  26.     }
  27. }

Наконец, основной контроллер src/Controllers/AppController.php:
  1. <?php
  2.  
  3. namespace Controllers;
  4.  
  5. class AppController extends AbstractController {
  6.  
  7.     public function index() {
  8.         $user = $this->db()
  9.             ->query('SELECT * FROM users WHERE id = 1')
  10.             ->fetch();
  11.         $this->render('index.twig', ['user' => $user]);
  12.     }
  13.  
  14.     public function about() {
  15.         $this->render('about.twig');
  16.     }
  17. }

И шаблоны src/views/about.twig:
  1. {% set meta = app.config.metadata %}
  2. <h2>About page</h2>
  3. <b>Name</b> {{ meta.name }}<br/>
  4. <b>Copyright</b>: {{ meta.copyright }}<br/>

src/views/404.twig:
  1. <h2>404</h2>
  2. Sorry, the page you are looking for could not be found.

shot-20201124T164630.png shot-20201124T164724.png


Разметка
Пришло время использовать Twig по назначению, создадим разметку src/views/layout/main.twig:
  1. {% set meta = app.config.metadata %}
  2. <!DOCTYPE html>
  3. <html lang="en">
  4. <head>
  5.   <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  6.   <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, user-scalable=yes" />
  7.   <link rel="stylesheet" href="/dist/css/styles.css" />
  8.   <title>{% block title %}{% endblock %} | {{ meta.name }}</title>
  9.   {% block head %}{% endblock %}
  10. </head>
  11. <body>
  12.   <div class="container">
  13.     {% block content %}{% endblock %}
  14.     <div class="footer p-1 bg-light text-muted">{{ meta.copyright }} {{ "now"|date('Y') }}</div>
  15.   </div>
  16.   {% block scripts %}
  17.     <script src="/dist/js/app.js"></script>
  18.   {% endblock %}
  19. </body>
  20. </html>
Внутри разметки есть блоки, которые можно переопределить и заполнить. Так код страницы будет проще понять.

src/views/index.twig:
  1. {% extends "layout/main.twig" %}
  2.  
  3. {% block title %}Index{% endblock %}
  4.  
  5. {% block content %}
  6.   <nav class="nav navbar-light bg-light">
  7.     <a class="nav-link active" href="/">{{ meta.name}}</a>
  8.     <a class="nav-link" href="/about/">About</a>
  9.   </nav>
  10.   <main class="col-md-8 py-md-2 pl-md-3 bd-content">
  11.     <b>Name</b>: {{ meta.name }}<br/>
  12.     <b>Message</b>: {{ meta.message }}<br/>
  13.     <b>Copyright</b>: {{ meta.copyright }}<br/>
  14.     <b>Username</b>: {{ user.name }}<br/>
  15.     <b>User</b>: <pre>{{ dump(user) }}</pre>
  16.   </main>
  17. {% endblock %}


JavaScript и CSS
В качестве бонуса покажу вариант с препроцессором SCSS и минификацией JS.

src/package.json:
  1. {
  2.   "name": "app-site",
  3.   "version": "1.0.0",
  4.   "description": "",
  5.   "main": "index.js",
  6.   "dependencies": {
  7.     "bootstrap": "^4.5.3"
  8.   },
  9.   "devDependencies": {
  10.     "uglify-js": "^3.12.0"
  11.   },
  12.   "scripts": {
  13.     "test": "echo \"Error: no test specified\" && exit 1",
  14.     "scss": "sass --no-source-map --style=compressed scss/styles.scss:../public/dist/css/styles.css",
  15.     "uglify": "uglifyjs js/*.js -c -m -o ../public/dist/js/app.js",
  16.     "dist": "npm run scss && npm run uglify"
  17.   },
  18.   "author": "aNNiMON",
  19.   "license": "MIT"
  20. }

Установим какой-нибудь CSS-фреймворк (я взял bootstrap) и dev-зависимость uglify-js для минификации js.
  1. cd src && npm install

Подключим некоторые компоненты бутстрапа в src/scss/styles.scss:
  1. @import "../node_modules/bootstrap/scss/functions";
  2. @import "../node_modules/bootstrap/scss/variables";
  3. @import "../node_modules/bootstrap/scss/mixins";
  4. @import "../node_modules/bootstrap/scss/reboot";
  5. @import "../node_modules/bootstrap/scss/type";
  6. @import "../node_modules/bootstrap/scss/alert";
  7. @import "../node_modules/bootstrap/scss/card";
  8. @import "../node_modules/bootstrap/scss/grid";
  9. @import "../node_modules/bootstrap/scss/nav";
  10. @import "../node_modules/bootstrap/scss/utilities";

Теперь можно преобразовать scss в минифицированный css командой
  1. npm run scss
Либо вместе с минификацией js:
  1. npm run dist

final-app.gif


Как использовать шаблон
Чтобы не проходить все перечисленные шаги, можно воспользоваться проектом как шаблоном. Репозиторий: https://github.com/annimon-tutorials/php-unframework-template

  1. git clone https://github.com/annimon-tutorials/php-unframework-template.git my-app
  2. cd my-app
  3. composer install
  4. chmod -R 777 cache
  5. cd src && npm install
  6. npm run dist

Клонируем репо, устанавливаем зависимости и собираем стили и js. Останется только настроить сервер, воспользовавшись конфигами из раздела Настройка nginx/Apache.
  • +5
  • views 2716