[webshake] Продвинутый курс «ООП в PHP» - Часть 2

Архитектура MVC - Model, View, Controller

В сегодняшнем уроке мы вообще не будем писать код. Вместо этого мы поговорим о том, как вообще построить приложение на PHP так, чтобы самому в нём не запутаться. Мы поговорим о том, что вообще такое архитектура приложения. А после этого мы разберем пример архитектуры на паттерне проектирования MVC и рассмотрим, как его использовать в разработке программ на языке PHP.

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

Что же такое эта архитектура?

Архитектура программного обеспечения (англ. software architecture) — совокупность важнейших решений об организации программной системы. Архитектура включает:

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

Википедия

Если говорить проще, то архитектура это про то, как:

  • разделить приложение на какие-то блоки;
  • разложить эти блоки по своим местам;
  • связать эти блоки между собой.

MVC

Архитектура приложения должна отражать в себе концепцию того, как вообще работает приложение. Если говорить о проектах на языке PHP, то в подавляющем большинстве случаев – это веб-сайты.
Согласитесь, все веб-сайты работают примерно одинаково:

  • получение и обработка запроса от пользователя (GET-запрос на страничку со статьёй)
  • понимание того, как на этот запрос нужно отреагировать (получить статью из базы данных и вернуть её пользователю)
  • работа с данными, их получение/изменение в базе данных (получение статьи из базы данных)
  • формирование представления для пользователя (заполнение HTML-шаблона данными из базы данных)
  • отправка ответа пользователю (отправка сформированной HTML-странички с текстом статьи).

К нашему счастью, для такого сценария уже есть готовый паттерн проектирования, или, как ещё говорят – шаблон проектирования. Называется он MVC. Это аббревиатура трёх слов: Model, View, Controller. Это три блока, на которые будет делиться всё наше приложение.

Архитектура MVC

Model (Модель)

Модель – это часть приложения, работающая с данными. Она содержит в себе данные и умеет их отображать в базу данных. То есть она может добавлять записи в базу данных, удалять их, изменять, или же просто получать их оттуда.

Задача модели – взять данные и передать их тому, кто эти данные у неё запрашивает. Если мы посмотрим на рисунок того, как устроена архитектура MVC, мы видим, что модель взаимодействует с контроллером. Таким образом контроллер может получать данные от модели, либо же передавать эти данные в модель. С другой стороны от модели как правило находится база данных, в которой модель эти данные умеет хранить и получать их оттуда.

В коде, с которым мы работали до этого, модель – это классы Article и User. Они содержат в себе данные, хоть и не умеют пока работать с базой данных. Тем не менее мы можем получить данные из этих моделей или поместить их туда. Например, создать статью, а затем получить её текст.

View (Представление)

Эта часть программы довольно проста и отвечает за то, в каком виде пользователь получит данные от приложения. В этот блок приходят какие-то данные от контроллера, например, имя HTML-шаблона и переменные, которые в этот шаблон нужно передать.

Представьте что есть просто HTML-страничка, у которой вместо конкретного заголовка в исходнике написано:

<title><?= $title ?></title>

А переменная $title передаётся в этот шаблон из контроллера. Разумеется, мы проделаем всё это чуть позже, пока что не надо думать о том, как именно этого можно добиться.

Controller (Контроллер)

Это связующее звено между запросом от пользователя, моделями и представлением. Именно контроллер является точкой входа в приложение. Сюда приходит запрос от пользователя и принимается решение о том, что с этим запросом делать. Например, создать новую статью. Тогда контроллер создаёт новую модель данных для статьи и просит её сохраниться в базу данных. Затем он берёт эту статью и передаёт её в представление. Представление берёт шаблон для вывода статьи и переменные, полученные от контроллера (заголовок, текст статьи, её автора) и после этого возвращает сформированную страничку пользователю.

Заключение

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

Controller в MVC

Итак, мы разобрались за что должен отвечать каждый слой модели MVC. Давайте теперь остановимся более подробно на контроллере.

Контроллер – это точка входа в наше приложение + логика того, что вообще нужно сделать. Контроллер работает с моделями и передаёт результат во View.

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

Внутри папки src/MyProject создаём папку Controllers. А внутри неё – файл с именем MainController.php. Это будет контроллер для главной страницы сайта. Отсюда и название – Main.

Структура проекта

Содержимое этого файла делаем таким:
src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

class MainController
{
    public function main()
    {
        echo 'Главная страница';
    }
}

Теперь давайте вернёмся в файл index.php и в нём создадим объект этого класса и вызовем метод main().
www/index.php

<?php

spl_autoload_register(function (string $className) {
    require_once __DIR__ . '/../src/' . $className . '.php';
});

$controller = new \MyProject\Controllers\MainController();
$controller->main();

Давайте теперь откроем в браузере наш проект и увидим следующее.

Главная страница

Вуаля! Наш первый контроллер готов.

Разумеется, контроллер может быть не один, их может быть несколько. И у контроллера может быть несколько методов.

Давайте добавим метод sayHello() в тот же контроллер. Пусть у этого метода будет один строковый аргумент $name. И всё, что будет делать этот метод – это выводить строку «Привет, $name».
src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

class MainController
{
    public function main()
    {
        echo 'Главная страница';
    }

    public function sayHello(string $name)
    {
        echo 'Привет, ' . $name;
    }
}

Теперь давайте добавим в index.php обработку GET-параметра name. Если он не пустой, то мы будем вызывать метод sayHello() и передавать туда этот параметр. Иначе – мы будем вызывать метод main().
www/index.php

<?php

spl_autoload_register(function (string $className) {
    require_once __DIR__ . '/../src/' . $className . '.php';
});

$controller = new \MyProject\Controllers\MainController();

if (!empty($_GET['name'])) {
    $controller->sayHello($_GET['name']);
} else {
    $controller->main();
}

Теперь, если мы перейдём по адресу http://myproject.loc то увидим всё ту же «Главную страницу». Но если мы перейдём по адресу http://myproject.loc/?name=Иван то увидим сообщение «Привет, Иван».
Итак, мы разобрались с тем, что такое контроллер и с тем, что у него может быть несколько методов.

Публичные методы контроллера ещё называются action-ами (от англ. action - действие).

Чем же тогда является index.php? Это ведь и точка входа, и место, где мы создаём сам контроллер и вызываем его методы. Этот кусок кода называется фронт-контроллером. И в следующих уроках мы изучим, как можно его усовершенствовать, чтобы не писать кучу кода для создания других контроллеров и сделаем его более гибким.

Фронт-контроллер и роутинг в PHP

В прошлом уроке мы добавили в контроллер 2 экшена и стали проверять в index.php GET-параметр. В зависимости от этого параметра мы решали, какой из экшенов вызвать и что передать в качестве аргументов. А что будет, когда нам на сайте понадобится более 100 страниц? Для каждого добавлять if? Согласитесь, неудобно. В этом уроке мы сделаем удобную систему для обработки адресов сайта – роутинг (от англ. routing - маршрутизация).

Если вы не работали ранее с регулярными выражениями – пройдите урок по регуляркам в PHP.

Apache RewriteEngine

Для начала немного магии. Создайте в директории www файл .htaccess и запишите в него следующее содержимое:

RewriteEngine On

RewriteCond %{SCRIPT_FILENAME} !-d
RewriteCond %{SCRIPT_FILENAME} !-f

RewriteRule ^(.*)$ ./index.php?route=$1 [QSA,L]

Это – специальный файл с конфигурацией для веб-сервера Apache. Если забыли – то это именно он обрабатывает запросы от пользователя и передаёт их дальше интерпретатору PHP. Подробнее об этом читайте в статье “как работает PHP”. Когда Apache находит в директории файл с именем .htaccess он понимает, что это его конфиг и применяет его для директории, в которой этот конфиг лежит (и для вложенных директорий тоже).

RewriteEngine – это такой механизм в сервере Apache, который позволяет перенаправлять запросы. А теперь давайте рассмотрим каждую строку файла отдельно.

  • RewriteEngine On – включаем режим перенаправления запросов
  • RewriteCond %{SCRIPT_FILENAME} !-d – если в директории есть папка, соответствующая адресу запроса, то отдать её в ответе
  • RewriteCond %{SCRIPT_FILENAME} !-f – если в директории есть файл, соответствующий адресу запроса, то вернуть его в ответе
  • RewriteRule ^(. )$ ./index.php?route=$1 [QSA,L] – если файл или папка не найдены, то для такого запроса выполнится этот пункт. В таком случае веб-сервер перенаправить этот запрос на скрипт index.php. При этом скрипту будет передан GET-параметр route со значением запрошенного адреса. $1 – это значение, выдернутое с помощью регулярки по маске ^(. )$. То есть вся адресная строка будет передана в этот GET-параметр.

Давайте теперь это проверим. Откроем в браузере адрес http://myproject.loc/abracadabra.

Опа! Видим текст «Главная страница». Значит мы попали на index.php. Давайте теперь попробуем в index.php вывести GET-параметр route. Уберём пока код, добавленный на предыдущих уроках и оставим только автозагрузку классов и вывод этого GET-параметра.

<?php

spl_autoload_register(function (string $className) {
    require_once __DIR__ . '/../src/' . $className . '.php';
});

var_dump($_GET['route']);

Снова откроем тот же адрес http://myproject.loc/abracadabra и увидим следующее:

string 'abracadabra' (length=11)

Давайте попробуем другой адрес - http://myproject.loc/hello/username

string 'hello/username' (length=14)

ЧПУ

Такие адреса через слэши называются ЧПУ – Человеко Понятные УРЛы. То есть адреса, которые нормально воспринимаются человеком.
Согласитесь
http://myproject.loc/hello/username
лучше чем
http://myproject.loc/?action=hello&name=username

На таких ЧПУ-адресах мы и будем разрабатывать нашу систему.

Роутинг

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

Для начала давайте сделаем по-простому – с помощью регулярки научимся понимать, что текущий адрес: http://myproject.loc/hello/ , где - вообще любая строка.

<?php

spl_autoload_register(function (string $className) {
    require_once __DIR__ . '/../src/' . $className . '.php';
});

$route = $_GET['route'] ?? '';

$pattern = '~^hello/(.*)$~';
preg_match($pattern, $route, $matches);

var_dump($matches);

Обратите внимание – в качестве ограничителя шаблона регулярного выражения мы использовали тильду - ~ . Мы выбрали её вместо слэша, чтобы не экранировать слэш в адресной строке. Напомню, что в качестве ограничителя может выступать вообще любой символ.

Перейдём по адресу http://myproject.loc/hello/username и увидим наши совпадения по регулярке:

array (size=2)
  0 => string 'hello/username' (length=14)
  1 => string 'username' (length=8)

Нулевой элемент – полное совпадение по паттерну. Первый элемент – значение, попавшее в маску (.*), то есть всё, что идёт после hello/ .

Давайте теперь добавим проверку того, что если $matches не пустой, то будем создавать контроллер MainController и вызывать у него экшен hello. В качестве аргумента будем передавать ему значение из массива по ключу 1.

<?php

spl_autoload_register(function (string $className) {
    require_once __DIR__ . '/../src/' . $className . '.php';
});

$route = $_GET['route'] ?? '';

$pattern = '~^hello/(.*)$~';
preg_match($pattern, $route, $matches);

if (!empty($matches)) {
    $controller = new \MyProject\Controllers\MainController();
    $controller->sayHello($matches[1]);
    return;
}

Посмотрим, что получилось.

Привет, username

Отлично! Давайте теперь добавим обработку случая, когда мы просто зашли на http://myproject.loc/. В таком случае переменная route будет пустой строкой. Регулярка для такого случая - ^$ . Да, просто начало строки и конец строки. Проще не бывает!

<?php

spl_autoload_register(function (string $className) {
    require_once __DIR__ . '/../src/' . $className . '.php';
});

$route = $_GET['route'] ?? '';

$pattern = '~^hello/(.*)$~';
preg_match($pattern, $route, $matches);

if (!empty($matches)) {
    $controller = new \MyProject\Controllers\MainController();
    $controller->sayHello($matches[1]);
    return;
}

$pattern = '~^$~';
preg_match($pattern, $route, $matches);

if (!empty($matches)) {
    $controller = new \MyProject\Controllers\MainController();
    $controller->main();
    return;
}

Перейдём теперь на страницу http://myproject.loc/ и увидим сообщение «Главная страница».
Остаётся только добавить обработку случая, когда ни одна из этих регулярок не подошла и просто вывести сообщение о том что страница не найдена.

Давайте просто добавим в конце index.php строку:

...
echo 'Страница не найдена';

И проверим, что всё работает, перейдя по любому другому адресу: http://myproject.loc/blabla.

А теперь давайте посмотрим на получившийся код и подумаем, нет ли здесь чего-то общего? Да конечно есть! В обоих случаях мы проверяем регулярки и в зависимости от совпадения создаём нужный контроллер, с нужным методом и нужными аргументами. Но механизм-то одинаковый! Значит, будем делать универсальную систему роутинга.

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

Давайте создадим отдельный файл с такой конфигурацией. Пусть это будет файл src/routes.php. Запишем в него следующее содержимое:
src/routes.php

<?php

return [
    '~^hello/(.*)$~' => [\MyProject\Controllers\MainController::class, 'sayHello'],
    '~^$~' => [\MyProject\Controllers\MainController::class, 'main'],
];

То есть это просто массив, у которого ключи – это регулярка для адреса, а значение – это массив с двумя значениями – именем контроллера и названием метода.

Теперь вернёмся в index.php и научимся обрабатывать этот файл. Для начала давайте просто положим этот массив в отдельную переменную.

<?php

spl_autoload_register(function (string $className) {
    require_once __DIR__ . '/../src/' . $className . '.php';
});

$route = $_GET['route'] ?? '';
$routes = require __DIR__ . '/../src/routes.php';
var_dump($routes);

Результат:

array (size=2)
  '~^hello/(.*)$~' => 
    array (size=2)
      0 => string 'MyProject\Controllers\MainController' (length=36)
      1 => string 'sayHello' (length=8)
  '~^$~' => 
    array (size=2)
      0 => string 'MyProject\Controllers\MainController' (length=36)
      1 => string 'main' (length=4)

Что с этим делать? Да просто пробежаться по нему foreach-ом и найти соответствие по регулярке для текущего адреса. Как только совпадение найдено, нужно остановить перебор. Звучит несложно. Давайте сделаем это!

<?php

spl_autoload_register(function (string $className) {
    require_once __DIR__ . '/../src/' . $className . '.php';
});

$route = $_GET['route'] ?? '';
$routes = require __DIR__ . '/../src/routes.php';

$isRouteFound = false;
foreach ($routes as $pattern => $controllerAndAction) {
    preg_match($pattern, $route, $matches);
    if (!empty($matches)) {
        $isRouteFound = true;
        break;
    }
}

if (!$isRouteFound) {
    echo 'Страница не найдена!';
    return;
}

var_dump($controllerAndAction);
var_dump($matches);

Я завел также специальную переменную $isRouteFound – на случай, если совпадений не было найдено, она останется false, как и до перебора. В таком случае мы выведем сообщение о том, что страница не найдена и завершим работу скрипта. В противном случае – выведем значение переменных $controllerAndAction и $matches.

Давайте проверим случай, когда нужный роут не найден - http://myproject.loc/blabla

Страница не найдена!

Всё правильно. Давайте теперь вернёмся на http://myproject.loc/

array (size=2)
  0 => string 'MyProject\Controllers\MainController' (length=36)
  1 => string 'main' (length=4)
array (size=1)
  0 => string '' (length=0)

Видим, что у нас есть имя нужного контроллера и имя метода. Всё, этого достаточно. Вот так это делается в PHP:

<?php

spl_autoload_register(function (string $className) {
    require_once __DIR__ . '/../src/' . $className . '.php';
});

$route = $_GET['route'] ?? '';
$routes = require __DIR__ . '/../src/routes.php';

$isRouteFound = false;
foreach ($routes as $pattern => $controllerAndAction) {
    preg_match($pattern, $route, $matches);
    if (!empty($matches)) {
        $isRouteFound = true;
        break;
    }
}

if (!$isRouteFound) {
    echo 'Страница не найдена!';
    return;
}

$controllerName = $controllerAndAction[0];
$actionName = $controllerAndAction[1];

$controller = new $controllerName();
$controller->$actionName();

Да! Прямо вот так! Переменную можно использовать в качестве имени класса при создании объекта, и даже вместо имени метода!

Зайдите на http://myproject.loc/ и убедитесь, что всё прекрасно работает.

Но у нас осталась еще проблема с аргументами для методов.

Давайте вернёмся к предыдущему варианту кода, где мы просто вывели значения переменных:

...
if (!$isRouteFound) {
    echo 'Страница не найдена!';
    return;
}

var_dump($controllerAndAction);
var_dump($matches);

И перейдём по адресу http://myproject.loc/hello/username
Результат:

array (size=2)
  0 => string 'MyProject\Controllers\MainController' (length=36)
  1 => string 'sayHello' (length=8)
array (size=2)
  0 => string 'hello/username' (length=14)
  1 => string 'username' (length=8)

Видим что у нас так же есть имя контроллера и имя метода. А также нужный нам аргумент в массиве $matches.

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

...
if (!$isRouteFound) {
    echo 'Страница не найдена!';
    return;
}

unset($matches[0]);

var_dump($controllerAndAction);
var_dump($matches);

Получаем следующую картину:

array (size=2)
  0 => string 'MyProject\Controllers\MainController' (length=36)
  1 => string 'sayHello' (length=8)
array (size=1)
  1 => string 'username' (length=8)

Остаётся только один вопрос – как элементы массива передать в аргументы метода? Для этого в PHP есть специальный оператор троеточия:

method(...$array)

Он передаст элементы массива в качестве аргументов методу в том порядке, в котором они находятся в массиве.

Теперь доводим до ума наш скрипт:

<?php

spl_autoload_register(function (string $className) {
    require_once __DIR__ . '/../src/' . $className . '.php';
});

$route = $_GET['route'] ?? '';
$routes = require __DIR__ . '/../src/routes.php';

$isRouteFound = false;
foreach ($routes as $pattern => $controllerAndAction) {
    preg_match($pattern, $route, $matches);
    if (!empty($matches)) {
        $isRouteFound = true;
        break;
    }
}

if (!$isRouteFound) {
    echo 'Страница не найдена!';
    return;
}

unset($matches[0]);

$controllerName = $controllerAndAction[0];
$actionName = $controllerAndAction[1];

$controller = new $controllerName();
$controller->$actionName(...$matches);

Переходим по адресу http://myproject.loc/hello/username и видим что всё работает!

Вот мы и сделали роутинг. Теперь если нам понадобится добавить новый адрес на сайте то мы просто пропишем его в routes.php, и укажем имя контроллера и метода. Остальное произойдёт автоматически!

Ах да, наш index.php - скрипт, в котором происходит обработка входящих запросов и создаются другие контроллеры, называется фронт-контроллером .

Домашнее задание

Создайте еще один экшн в контроллере – sayBye(string $name), который будет выводить «Пока, $name». Добавьте для него роут /bye/$name и убедитесь, что всё работает.

View в MVC

Сегодня мы сделаем компонент View, то самое “V” в архитектуре MVC. View – это представление, то есть та часть программы, которая формирует то, что видит пользователь.

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

Итак, давайте рассмотрим простейший пример и создадим для начала только шаблон. Путь до него будет следующим: templates/main/main.php

Шаблон main.php

Давайте запишем в него HTML-код для нашей будущей странички
templates/main/main.php

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>Мой блог</title>
    <style>
        .layout {
            width: 100%;
            max-width: 1024px;
            margin: auto;
            background-color: white;
            border-collapse: collapse;
        }

        .layout tr td {
            padding: 20px;
            vertical-align: top;
            border: solid 1px gray;
        }

        .header {
            font-size: 30px;
        }

        .footer {
            text-align: center;
        }

        .sidebarHeader {
            font-size: 20px;
        }

        .sidebar ul {
            padding-left: 20px;
        }

        a, a:visited {
            color: darkgreen;
        }
    </style>
</head>
<body>

<table class="layout">
    <tr>
        <td colspan="2" class="header">
            Мой блог
        </td>
    </tr>
    <tr>
        <td>
            <h2>Статья 1</h2>
            <p>Всем привет, это текст первой статьи</p>
            <hr>

            <h2>Статья 2</h2>
            <p>Всем привет, это текст второй статьи</p>
        </td>

        <td width="300px" class="sidebar">
            <div class="sidebarHeader">Меню</div>
            <ul>
                <li><a href="/">Главная страница</a></li>
                <li><a href="/about-me">Обо мне</a></li>
            </ul>
        </td>
    </tr>
    <tr>
        <td class="footer" colspan="2">Все права защищены (c) Мой блог</td>
    </tr>
</table>

</body>
</html>

Теперь давайте откроем наш контроллер MainController и изменим его метод main()
src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

class MainController
{
    public function main()
    {
        include __DIR__ . '/../../../templates/main/main.php';
    }
}

Теперь откроем http://myproject.loc/ и полюбуемся результатом:

Результат вывода шаблона

Для начала давайте немного облегчим шаблон и вынесем стили в отдельный файл. Для этого в папке www создадим файл styles.css.
www/styles.css

.layout {
    width: 100%;
    max-width: 1024px;
    margin: auto;
    background-color: white;
    border-collapse: collapse;
}

.layout tr td {
    padding: 20px;
    vertical-align: top;
    border: solid 1px gray;
}

.header {
    font-size: 30px;
}

.footer {
    text-align: center;
}

.sidebarHeader {
    font-size: 20px;
}

.sidebar ul {
    padding-left: 20px;
}

a, a:visited {
    color: darkgreen;
}

Теперь подключим этот файл со стилями в шаблоне:

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>Мой блог</title>
    <link rel="stylesheet" href="/styles.css">
</head>
<body>

<table class="layout">
    <tr>
        <td colspan="2" class="header">
            Мой блог
        </td>
    </tr>
    <tr>
        <td>
            <h2>Статья 1</h2>
            <p>Всем привет, это текст первой статьи</p>
            <hr>

            <h2>Статья 2</h2>
            <p>Всем привет, это текст второй статьи</p>
        </td>

        <td width="300px" class="sidebar">
            <div class="sidebarHeader">Меню</div>
            <ul>
                <li><a href="/">Главная страница</a></li>
                <li><a href="/about-me">Обо мне</a></li>
            </ul>
        </td>
    </tr>
    <tr>
        <td class="footer" colspan="2">Все права защищены (c) Мой блог</td>
    </tr>
</table>

</body>
</html>

И снова убедимся, что всё работает.
Отображение при подключении внешних стилей

Давайте теперь попробуем передавать в шаблон переменные. Вместо явно заданных статей сделаем переменную со статьями:
src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

class MainController
{
    public function main()
    {
        $articles = [
            ['name' => 'Статья 1', 'text' => 'Текст статьи 1'],
            ['name' => 'Статья 2', 'text' => 'Текст статьи 2'],
        ];
        include __DIR__ . '/../../../templates/main/main.php';
    }
}

А теперь выведем эти статьи в шаблоне:
templates/main/main.php

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>Мой блог</title>
    <link rel="stylesheet" href="/styles.css">
</head>
<body>

<table class="layout">
    <tr>
        <td colspan="2" class="header">
            Мой блог
        </td>
    </tr>
    <tr>
        <td>
            <?php foreach ($articles as $article): ?>
                <h2><?= $article['name'] ?></h2>
                <p><?= $article['text'] ?></p>
                <hr>
            <?php endforeach; ?>
        </td>

        <td width="300px" class="sidebar">
            <div class="sidebarHeader">Меню</div>
            <ul>
                <li><a href="/">Главная страница</a></li>
                <li><a href="/about-me">Обо мне</a></li>
            </ul>
        </td>
    </tr>
    <tr>
        <td class="footer" colspan="2">Все права защищены (c) Мой блог</td>
    </tr>
</table>

</body>
</html>

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

Создадим класс View.php по пути src/MyProject/View/View.php
Класс View

В конструкторе этого класса мы будем принимать путь до папки с шаблонами:
src/MyProject/View/View.php

<?php

namespace MyProject\View;

class View
{
    private $templatesPath;

    public function __construct(string $templatesPath)
    {
        $this->templatesPath = $templatesPath;
    }
}

Помимо этого давайте добавим метод, в который будем передавать имя конкретного шаблона и массив с переменными.

<?php

namespace MyProject\View;

class View
{
    private $templatesPath;

    public function __construct(string $templatesPath)
    {
        $this->templatesPath = $templatesPath;
    }

    public function renderHtml(string $templateName, array $vars = [])
    {
        extract($vars);

        include $this->templatesPath . '/' . $templateName;
    }
}

Функция extract извлекает массив в переменные. То есть она делает следующее: в неё передаётся массив [‘key1’ => 1, ‘key2’ => 2], а после её вызова у нас имеются переменные $key1 = 1 и $key2 = 2.

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

Пришло время опробовать этот код в нашем контроллере. Создадим новый объект View в конструкторе контроллера, а затем внутри экшена вызовем renderHtml().
src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

use MyProject\View\View;

class MainController
{
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function main()
    {
        $articles = [
            ['name' => 'Статья 1', 'text' => 'Текст статьи 1'],
            ['name' => 'Статья 2', 'text' => 'Текст статьи 2'],
        ];
        $this->view->renderHtml('main/main.php', ['articles' => $articles]);
    }
}

Теперь мы можем снова открыть сайт, и убедиться, что всё прекрасно работает.

Буфер вывода

В тот момент, когда мы подключаем файл c HTML-кодом, либо пишем в PHP-коде echo, либо совершаем какой-либо другой вывод данных, эти данные начинают сразу передаваться в поток вывода. И если что-то пойдёт не так, мы не сможем вернуть этот вывод и вывести вместо него какую-нибудь ошибку. Но в PHP есть возможность весь этот поток вывода положить во временный буфер вывода. Выглядит его использование следующим образом:
src/MyProject/View/View.php

public function renderHtml(string $templateName, array $vars = [])
{
    extract($vars);

    ob_start();
    include $this->templatesPath . '/' . $templateName;
    $buffer = ob_get_contents();
    ob_end_clean();
}

Если вы сейчас попробуете запустить наш скрипт, то увидите пустую страницу. Дело в том, что все данные, которые должны были быть переданы в поток вывода, оказались в переменной $buffer.
Для того, чтобы передать эти данные в поток вывода, достаточно только вывести переменную $buffer.
src/MyProject/View/View.php

public function renderHtml(string $templateName, array $vars = [])
{
    extract($vars);

    ob_start();
    include $this->templatesPath . '/' . $templateName;
    $buffer = ob_get_contents();
    ob_end_clean();

    echo $buffer;
}

Откройте страничку снова, и убедитесь, что всё вернулось на свои места.

Так в чём же профит? А профит в том, что мы можем обрабатывать ошибки, возникшие в процессе работы с шаблоном. Пока мы с вами не знакомы с понятием «Исключения», давайте предположим, что у нас при подключении шаблона произошла какая-то ошибка. Тогда мы могли бы обработать эту ошибку и не выводить пользователю неправильно отрисованный шаблон. Мы могли бы сделать что-то типа такого:
src/MyProject/View/View.php

public function renderHtml(string $templateName, array $vars = [])
{
    extract($vars);

    ob_start();
    include $this->templatesPath . '/' . $templateName;
    $buffer = ob_get_contents();
    ob_end_clean();

    $error = 'В шаблоне была ошибка!';

    if (empty($error)) {
        echo $buffer;
    } else {
        echo $error;
    }
}

Чуть позже мы вернёмся к обработке возможных ошибок, когда познакомимся с исключениями. А пока оставим этот код в таком состоянии:
src/MyProject/View/View.php

public function renderHtml(string $templateName, array $vars = [])
{
    extract($vars);

    ob_start();
    include $this->templatesPath . '/' . $templateName;
    $buffer = ob_get_contents();
    ob_end_clean();

    echo $buffer;
}

Реиспользование шаблонов

Давайте в наш контроллер вернём экшн из прошлых уроков, который выводил приветствие.
src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

use MyProject\View\View;

class MainController
{
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function main()
    {
        $articles = [
            ['name' => 'Статья 1', 'text' => 'Текст статьи 1'],
            ['name' => 'Статья 2', 'text' => 'Текст статьи 2'],
        ];
        $this->view->renderHtml('main/main.php', ['articles' => $articles]);
    }

    public function sayHello(string $name)
    {
        echo 'Привет, ' . $name;
    }
}

Давайте изменим его, чтобы он работал через шаблон.

public function sayHello(string $name)
{
    $this->view->renderHtml('main/hello.php', ['name' => $name]);
}

Ну и создадим сам шаблон для него.
templates/main/hello.php

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>Мой блог</title>
    <link rel="stylesheet" href="/styles.css">
</head>
<body>

<table class="layout">
    <tr>
        <td colspan="2" class="header">
            Мой блог
        </td>
    </tr>
    <tr>
        <td>
            Привет, <?= $name ?>!!!
        </td>

        <td width="300px" class="sidebar">
            <div class="sidebarHeader">Меню</div>
            <ul>
                <li><a href="/">Главная страница</a></li>
                <li><a href="/about-me">Обо мне</a></li>
            </ul>
        </td>
    </tr>
    <tr>
        <td class="footer" colspan="2">Все права защищены (c) Мой блог</td>
    </tr>
</table>

</body>
</html>

Давайте теперь перейдём по адресу http://myproject.loc/hello/username и увидим, что всё прекрасно сработало:

Рендеринг другого шаблона

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

Итак, выносим верхнюю часть (так называемую шапку сайта - хедер) в новый файл templates/header.php
templates/header.php

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>Мой блог</title>
    <link rel="stylesheet" href="/styles.css">
</head>
<body>

<table class="layout">
    <tr>
        <td colspan="2" class="header">
            Мой блог
        </td>
    </tr>
    <tr>
        <td>

Затем выносим нижнюю часть (называемую футером или подвалом) в файл templates/footer.php
templates/footer.php

        </td>

        <td width="300px" class="sidebar">
            <div class="sidebarHeader">Меню</div>
            <ul>
                <li><a href="/">Главная страница</a></li>
                <li><a href="/about-me">Обо мне</a></li>
            </ul>
        </td>
    </tr>
    <tr>
        <td class="footer" colspan="2">Все права защищены (c) Мой блог</td>
    </tr>
</table>

</body>
</html>

После чего редактируем наши шаблоны:
templates/main/main.php

<?php include __DIR__ . '/../header.php'; ?>
<?php foreach ($articles as $article): ?>
    <h2><?= $article['name'] ?></h2>
    <p><?= $article['text'] ?></p>
    <hr>
<?php endforeach; ?>
<?php include __DIR__ . '/../footer.php'; ?>

templates/main/hello.php

<?php include __DIR__ . '/../header.php'; ?>
Привет, <?= $name ?>!!!
<?php include __DIR__ . '/../footer.php'; ?>

Должна получиться вот такая структура в шаблонах:
Структура папки с шаблонами

После этого заходим на странички http://myproject.loc/hello/username и http://myproject.loc/ и радуемся результату :slight_smile:

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

Давайте повторим последовательность шагов, которые необходимо сделать для добавления новой странички:

  1. Добавляем экшн в контроллер (либо создаём ещё и новый контроллер);
  2. Добавляем для него роут в routes.php;
  3. Описываем логику внутри экшена и в конце вызываем у компонента view метод renderHtml();
  4. Создаём шаблон для вывода результата.

Вот и весь View.

Создаём базу данных для будущего блога

Сегодня мы с вами создадим базу данных для нашего будущего блога. Пока что она будет состоять только из двух таблиц – пользователи и статьи. Но этого уже будет достаточно для того чтобы начать работать с ней из PHP. Поехали!

Этот и дальнейшие уроки предполагают, что вы имеете базовые представления о работе с базой данных MySQL. Если же нет – пройдите наш бесплатный курс MySQL с нуля, прежде чем идти дальше.

Итак, в первую очередь давайте создадим новую базу данных для нашего приложения. Если у вас как и у меня установлен OpenServer, то все действия будут такими же. Иначе – сами мучайтесь :D.

Открываем phpMyAdmin http://127.0.0.1/openserver/phpmyadmin/server_databases.php и открываем вкладку «Базы данных». Вводим имя нашей новой базы - my_project, и выбираем сравнение utf8_general_ci.

Таблица пользователей

Давайте теперь создадим таблицу с пользователями. Назовём её users и создадим в ней следующие поля:

  • id с типом INT и типом AUTO_INCREMENT – это id пользователя
  • nickname c типом VARCHAR, длиной в 128 символов и уникальным индексом – чтобы никнейм мог принадлежать только одному пользователю и не было дублей
  • email c типом VARCHAR, длиной в 255 символов и уникальным индексом – чтобы один email можно было использовать лишь единожды
  • is_confirmed с типом BOOLEAN – подтверждён ли email (по умолчанию - false)
  • role с типом ENUM и двумя возможными значениями – admin и user. Здесь будем хранить тип пользователя.
  • password_hash с типом VARCHAR и длиной в 255 символов – тут будем хранить хеш пользовательского пароля
  • auth_token с типом VARCHAR и длиной в 255 символов – тут будем хранить авторизационный токен, мы пока не изучали что это такое, пока просто добавим, позже пригодится
  • created_at с типом DATETIME – тут будет храниться дата и время создания статьи. По умолчанию в качестве значения будет использоваться CURRENT_TIMESTAMP – то есть текущие дата и время.

структура таблицы пользователей

Запрос для создания таблицы:

CREATE TABLE `users` (
  `id` int(11) NOT NULL,
  `nickname` varchar(128) NOT NULL,
  `email` varchar(255) NOT NULL,
  `is_confirmed` tinyint(1) NOT NULL DEFAULT '0',
  `role` enum('admin','user') NOT NULL,
  `password_hash` varchar(255) NOT NULL,
  `auth_token` varchar(255) NOT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

ALTER TABLE `users`
  ADD PRIMARY KEY (`id`),
  ADD UNIQUE KEY `nickname` (`nickname`),
  ADD UNIQUE KEY `email` (`email`);

ALTER TABLE `users`
  MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;

Давайте теперь добавим пару пользователей: админа и простого пользователя. Для этого выполним запрос:

INSERT INTO `users` (`id`, `nickname`, `email`, `is_confirmed`, `role`, `password_hash`, `auth_token`, `created_at`) VALUES (NULL, 'admin', 'admin@gmail.com', '1', 'admin', 'hash1', 'token1', CURRENT_TIMESTAMP);
INSERT INTO `users` (`id`, `nickname`, `email`, `is_confirmed`, `role`, `password_hash`, `auth_token`, `created_at`) VALUES (NULL, 'user', 'user@gmail.com', '1', 'user', 'hash2', 'token2', CURRENT_TIMESTAMP);

Зайдём в нашу табличку users и убедимся в том, что появилось 2 новых записи.

Записи о пользователях

Таблица для статей

Теперь давайте создадим таблицу «articles» для будущих статей.
Определяем следующие столбцы:

  • id с типом int и типом AUTO_INCREMENT (A_I) – id самой статьи;
  • author_id с типом int – это id автора статьи;
  • name c типом VARCHAR и длиной 255 – название статьи;
  • text с типом TEXT – текст статьи;
  • created_at с типом DATETIME – тут будет храниться дата и время создания статьи. По умолчанию в качестве значения будет использоваться CURRENT_TIMESTAMP – то есть текущие дата и время.

Создание таблицы для статей

Жмем «вперед» и получаем следующую картину:

Структура таблицы со статьями

Запрос для создания таблицы:

CREATE TABLE `articles` (
  `id` int(11) NOT NULL,
  `author_id` int(11) NOT NULL,
  `name` varchar(255) NOT NULL,
  `text` text NOT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

ALTER TABLE `articles`
  ADD PRIMARY KEY (`id`);

ALTER TABLE `articles`
  MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;

Ну и давайте добавим парочку новых статей. Выполним следующие запросы:

INSERT INTO `articles` (`id`, `author_id`, `name`, `text`, `created_at`) VALUES (NULL, '1', 'Статья о том, как я погулял', 'Шёл я значит по тротуару, как вдруг...', CURRENT_TIMESTAMP);
INSERT INTO `articles` (`id`, `author_id`, `name`, `text`, `created_at`) VALUES (NULL, '1', 'Пост о жизни', 'Сидел я тут на кухне с друганом и тут он задал такой вопрос...', CURRENT_TIMESTAMP);

Зайдём в табличку и полюбуемся на новые записи.

Записи о статьях

Вот и всё – мы создали простейшую базу данных для блога и даже добавили в неё несколько записей. В следующих уроках мы будем работать с неё с помощью PHP.

Класс для работы с базой данных

Так как мы учимся работать в ООП-стиле, то, как вы уже наверное догадались, мы будем использовать для соединения и работы с базой данных специальный класс. Давайте создадим в нашем проекте папку src/MyProject/Services и внутри неё создадим класс Db.php
src/MyProject/Services/Db.php

<?php

namespace MyProject\Services;

class Db
{

}

Давайте также создадим файл с настройками для подключения к базе данных. Он будет представлять собой простой массив.
src/settings.php

<?php

return [
    'db' => [
        'host' => 'localhost',
        'dbname' => 'my_project',
        'user' => 'root',
        'password' => '',
    ]
];

Если у вас как и у меня установлен OpenServer, то настройки будут такими же. Иначе – задавайте здесь свои настройки.

Теперь давайте в конструкторе нашего класса установим соединение с базой данных. Мы будем работать через PDO, подробнее об этом читайте в статье Взаимодействие PHP и MySQL.
src/MyProject/Services/Db.php

<?php

namespace MyProject\Services;

class Db
{
    /** @var \PDO */
    private $pdo;

    public function __construct()
    {
        $dbOptions = (require __DIR__ . '/../../settings.php')['db'];

        $this->pdo = new \PDO(
            'mysql:host=' . $dbOptions['host'] . ';dbname=' . $dbOptions['dbname'],
            $dbOptions['user'],
            $dbOptions['password']
        );
        $this->pdo->exec('SET NAMES UTF8');
    }
}

Свойство $this->pdo теперь можно использовать для работы с базой данных через PDO. Давайте напишем отдельный метод для выполнения запросов в базу.

<?php

namespace MyProject\Services;

class Db
{
    /** @var \PDO */
    private $pdo;

    public function __construct()
    {
        $dbOptions = (require __DIR__ . '/../../settings.php')['db'];

        $this->pdo = new \PDO(
            'mysql:host=' . $dbOptions['host'] . ';dbname=' . $dbOptions['dbname'],
            $dbOptions['user'],
            $dbOptions['password']
        );
        $this->pdo->exec('SET NAMES UTF8');
    }

    public function query(string $sql, $params = []): ?array
    {
        $sth = $this->pdo->prepare($sql);
        $result = $sth->execute($params);

        if (false === $result) {
            return null;
        }

        return $sth->fetchAll();
    }
}

Всё, класс для работы с базой данных готов. Давайте теперь попробуем его использовать для вывода статей на сайте прямо из базы.
src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

use MyProject\Services\Db;
use MyProject\View\View;

class MainController
{
    /** @var View */
    private $view;

    /** @var Db */
    private $db;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
        $this->db = new Db();
    }

    public function main()
    {
        $articles = $this->db->query('SELECT * FROM `articles`;');
        var_dump($articles);
        //$this->view->renderHtml('main/main.php', ['articles' => $articles]);
    }
}

Давайте посмотрим на результат.

Статьи в виде массива

То есть сейчас $articles – это массив с двумя вложенными элементами-массивами, представляющих собой статьи из базы данных.

Наш класс для работы с базой данных готов. В следующем уроке мы начнём его использовать для вывода статей на сайте.

Делаем вывод статей на сайте из базы данных

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

Давайте заглянем в наш шаблон.

templates/main/main.php

<?php include __DIR__ . '/../header.php'; ?>
<?php foreach ($articles as $article): ?>
    <h2><?= $article['name'] ?></h2>
    <p><?= $article['text'] ?></p>
    <hr>
<?php endforeach; ?>
<?php include __DIR__ . '/../footer.php'; ?>

Как видим, здесь у нас требуются ключи ‘name’ и ‘text’. И они есть у наших статей! Нам достаточно только передать эти статьи в шаблон, чтобы вывести их.

src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

use MyProject\Services\Db;
use MyProject\View\View;

class MainController
{
    /** @var View */
    private $view;

    /** @var Db */
    private $db;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
        $this->db = new Db();
    }

    public function main()
    {
        $articles = $this->db->query('SELECT * FROM `articles`;');
        $this->view->renderHtml('main/main.php', ['articles' => $articles]);
    }
}

Снова обновим страничку.
Статьи отрендеренные

Как видим, нужные данные успешно были использованы в шаблоне.
Давайте добавим на нашем сайте ещё одну страничку, на которой будет выводиться только одна статья.

Давайте создадим новый контроллер.

src/MyProject/Controllers/ArticlesController.php

<?php

namespace MyProject\Controllers;

use MyProject\Services\Db;
use MyProject\View\View;

class ArticlesController
{
    /** @var View */
    private $view;

    /** @var Db */
    private $db;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
        $this->db = new Db();
    }

    public function view()
    {
        echo 'Здесь будет получение статьи и рендеринг шаблона';
    }
}

Добавим новый роут. Пусть наши статьи будут открываться по адресу типа: http://myproject.loc/articles/1, где вместо 1 может быть любой другой id статьи.

src/routes.php

<?php

return [
    '~^articles/(\d+)$~' => [\MyProject\Controllers\ArticlesController::class, 'view'],
    '~^$~' => [\MyProject\Controllers\MainController::class, 'main'],
];

Давайте проверим, что наш роут успешно обрабатывается:
ЧПУ

Отлично, давайте теперь сделаем запрос в базу, в котором получим статью с нужным id.

src/MyProject/Controllers/ArticlesController.php

<?php

namespace MyProject\Controllers;

use MyProject\Services\Db;
use MyProject\View\View;

class ArticlesController
{
    /** @var View */
    private $view;

    /** @var Db */
    private $db;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
        $this->db = new Db();
    }

    public function view(int $articleId)
    {
        $result = $this->db->query(
            'SELECT * FROM `articles` WHERE id = :id;',
            [':id' => $articleId]
        );
        var_dump($result);
    }
}

Посмотрим на результат снова.
Одна статья в виде массива

Получили массив, в котором есть статья с id = 1.
Давайте посмотрим, что получится, если запросить статью с id = 2.
Вторая статья в виде массива

Всё снова хорошо отработало.
А что будет, если запросить статью, которой в базе нет?
Пустой массив

Мы получим пустой массив. Отлично, теперь давайте попробуем обработать эти две ситуации.

public function view(int $articleId)
{
    $result = $this->db->query(
        'SELECT * FROM `articles` WHERE id = :id;',
        [':id' => $articleId]
    );

    if ($result === []) {
        // Здесь обрабатываем ошибку
        return;
    }

    $this->view->renderHtml('articles/view.php', ['article' => $result[0]]);
}

Добавим шаблон для вывода одной статьи:

templates/articles/view.php

<?php include __DIR__ . '/../header.php'; ?>
    <h1><?= $article['name'] ?></h1>
    <p><?= $article['text'] ?></p>
<?php include __DIR__ . '/../footer.php'; ?>

И проверим, что всё ок.
Вывод статьи в шаблоне

Пришла пора добавить шаблон для страницы с ошибкой, когда что-то не найдено. Создадим ещё один шаблончик.

templates/errors/404.php

<h1>Страница не найдена</h1>

И будем подключать этот шаблон для случаев, когда наша статья не нашлась.

src/MyProject/Controllers/ArticlesController.php

...
public function view(int $articleId)
{
    $result = $this->db->query(
        'SELECT * FROM `articles` WHERE id = :id;',
        [':id' => $articleId]
    );

    if ($result === []) {
        $this->view->renderHtml('errors/404.php');
        return;
    }

    $this->view->renderHtml('articles/view.php', ['article' => $result[0]]);
}
...

Попробуем теперь открыть страничку с несуществующей статьёй.
Страница не найдена

Однако, просто так написать о том, что страница не найдена не верно. Важно при этом вернуть код ответа для страницы, который даст понять поисковым системам, что эту страницу индексировать не нужно. Если мы откроем панель разработчика в Google Chrome и перезагрузим страничку, мы увидим, что текущий код ответа – 200. Это стандартный код ответа, говорящий о том, что со страничкой всё хорошо.
200 код ответа

Нам же нужно вернуть код 404 – он говорит о том, что страница не найдена. Задать код ответа можно при помощи функции http_response_code(). В качестве аргумента ей передаётся код, который нужно вернуть.

Давайте отредактируем наш метод renderHtml() в классе View. Добавим возможность передавать код ответа.

src/MyProject/View/View.php

<?php

namespace MyProject\View;

class View
{
    private $templatesPath;

    public function __construct(string $templatesPath)
    {
        $this->templatesPath = $templatesPath;
    }

    public function renderHtml(string $templateName, array $vars = [], int $code = 200)
    {
        http_response_code($code);
        extract($vars);

        ob_start();
        include $this->templatesPath . '/' . $templateName;
        $buffer = ob_get_contents();
        ob_end_clean();

        echo $buffer;
    }
}

По умолчанию, если мы не передадим третьим аргументом код, будет возвращён 200-ый, иначе – заданный нами.

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

src/MyProject/Controllers/ArticlesController.php

<?php

namespace MyProject\Controllers;

use MyProject\Services\Db;
use MyProject\View\View;

class ArticlesController
{
    /** @var View */
    private $view;

    /** @var Db */
    private $db;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
        $this->db = new Db();
    }

    public function view(int $articleId)
    {
        $result = $this->db->query(
            'SELECT * FROM `articles` WHERE id = :id;',
            [':id' => $articleId]
        );

        if ($result === []) {
            $this->view->renderHtml('errors/404.php', [], 404);
            return;
        }

        $this->view->renderHtml('articles/view.php', ['article' => $result[0]]);
    }
}

Снова проверяем, обновив страничку.
404 код ответа

Вжух! Получили нужный код ошибки.

Итак, мы с вами сделали вывод списка статей и вывод каждой статьи отдельно. Уже немало. Давайте теперь сделаем ссылки на странице со списком, которые будут вести на отдельную статью. Для этого нам нужно только немного поправить шаблон.

templates/main/main.php

<?php include __DIR__ . '/../main/header.php'; ?>
<?php foreach ($articles as $article): ?>
    <h2><a href="/articles/<?= $article['id'] ?>"><?= $article['name'] ?></a></h2>
    <p><?= $article['text'] ?></p>
    <hr>
<?php endforeach; ?>
<?php include __DIR__ . '/../main/footer.php'; ?>

Зайдём в корень нашего сайтика и увидим, что теперь мы можем переходить по каждой статье отдельно.
Ссылки на статьи

На этом данный урок заканчивается, а в следующих мы научимся работать с базой данных, получая из неё объекты, а не массивы.

Домашнее задание

В экшне ArticlesController::view() после получения статьи, добавьте ещё один запрос на получение автора этой статьи из таблицы users. Выведите nickname автора в шаблоне.

ORM - Object Relational Mapping

ORM или Object-Relational Mapping (объектно-реляционное отображение) — технология программирования, которая связывает базы данных с концепциями объектно-ориентированных языков программирования.

Иными словами, это такой принцип, согласно которому мы работаем с базой данных в коде на уровне объектов. То есть структура и данные в базе данных будут иметь отражение в наших объектах в коде. И при работе с этими нашими объектами, мы будем изменять нашу базу данных, и наоборот. Вот как мы это будем делать:

  • таблицам будут соответствовать отдельные классы. Например, таблице articles будет соответствовать класс Article.
  • в классах будут описаны свойства объектов. Каждое свойство будет соответствовать полю в таблице. Например, будет свойство ->authorId, оно будет соответствовать столбцу author_id в таблице articles.
  • мы будем работать с объектами таких классов. Каждый такой объект соответствует одной записи в базе данных. То есть объект класса Article будет соответствовать одной строке в таблице articles.

Как видите, суть ORM крайне проста - объекты имеют своё отражение в базе данных. При этом в коде происходит работа на уровне объектов – вот так правильно делать это при объектно-ориентированном подходе.

Реализуем свою ORM

В течение этого курса мы с вами разработаем свою собственную ORM-систему – она будет позволять получать записи из базы данных в виде объектов, а также сохранять «объекты» в базу данных. В этом уроке мы сделаем наиболее простую часть этого функционала – научимся «читать объекты» из базы данных.

Первым делом давайте отредактируем наш класс Article, представляющий собой статью. В базе данных у нас есть следующие поля: id, name, text, author_id, created_at. Давайте сделаем в нашем классе свойства объектов, которые будут соответствовать этим полям.

src/MyProject/Models/Articles/Article.php

<?php

namespace MyProject\Models\Articles;

use MyProject\Models\Users\User;

class Article
{
    /** @var int */
    private $id;

    /** @var string */
    private $name;

    /** @var string */
    private $text;

    /** @var string */
    private $authorId;

    /** @var string */
    private $createdAt;
}

Теперь нужно каким-то образом при получении статей из базы данных создать объекты этого класса и заполнить их свойства значениями из базы данных. Для этого в PDO есть специальный режим. Всё что нужно сделать – это указать класс, объекты которого нужно создать. Давайте откроем класс Db и изменим метод query() следующим образом:

src/MyProject/Services/Db.php

    public function query(string $sql, array $params = [], string $className = 'stdClass'): ?array
{
    $sth = $this->pdo->prepare($sql);
    $result = $sth->execute($params);

    if (false === $result) {
        return null;
    }

    return $sth->fetchAll(\PDO::FETCH_CLASS, $className);
}

Третьим аргументом в этот метод будет передаваться имя класса, объекты которого нужно создавать. По умолчанию это будут объекты класса stdClass – это такой встроенный класс в PHP, у которого нет никаких свойств и методов.

В метод fetchAll() мы передали специальную константу - \PDO::FETCH_CLASS, она говорит о том, что нужно вернуть результат в виде объектов какого-то класса. Второй аргумент – это имя класса, которое мы можем передать в метод query().

Теперь зайдём в наш контроллер MainController и сделаем вывод результата запроса с помощью var_dump().

src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

use MyProject\Services\Db;
use MyProject\View\View;

class MainController
{
    /** @var View */
    private $view;

    /** @var Db */
    private $db;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
        $this->db = new Db();
    }

    public function main()
    {
        $articles = $this->db->query('SELECT * FROM `articles`;');
        var_dump($articles);
        return;
        $this->view->renderHtml('main/main.php', ['articles' => $articles]);
    }
}

Получение объектов класса stdClass из БД

Как видим, в результате мы получили массив объектов класса stdClass, у которых есть public-свойства, соответствующие именам столбцов в базе данных. В PHP мы можем задавать свойства объектов на лету, даже если они не были определены в классе. Это называется динамическим объявлением свойств. Если свойства у объекта нет, но мы попытаемся его задать – будет создано новое публичное свойство.

Давайте теперь попробуем в качестве класса передать имя класса Article:

src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Articles\Article;
use MyProject\Services\Db;
use MyProject\View\View;

class MainController
{
    /** @var View */
    private $view;

    /** @var Db */
    private $db;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
        $this->db = new Db();
    }

    public function main()
    {
        $articles = $this->db->query('SELECT * FROM `articles`;', [], Article::class);
        var_dump($articles);
        return;
        $this->view->renderHtml('main/main.php', ['articles' => $articles]);
    }
}

И снова запустим скрипт:
Массив объектов класса Article

И о чудо! Теперь у нас массив объектов класса Article! Однако, есть проблема. Свойства объектов ->authorId и ->createdAt остались со значениями null, но при этом у нас динамически добавилось два публичный свойства ->author_id и ->created_at. Так произошло из-за несоответствия имён столбцов в базе данных и свойств объектов класса Article.

Магический метод __set()

Эту проблему с несоответствием имён легко решить с помощью магического метода __set($name, $value) – если этот метод добавить в класс и попытаться задать ему несуществующее свойство, то вместо динамического добавления такого свойства, будет вызван этот метод. При этом в первый аргумент $name, попадёт имя свойства, а во второй аргумент $value – его значение. А внутри этого метода мы уже сможем решить, что с этими данными делать.

В качестве примера давайте добавим в класс Article этот метод. Всё, что он будет делать – это говорить о том, что он был вызван и какие аргументы были в него переданы.

src/MyProject/Models/Articles/Article.php

<?php

namespace MyProject\Models\Articles;

use MyProject\Models\Users\User;

class Article
{
    /** @var int */
    private $id;

    /** @var string */
    private $name;

    /** @var string */
    private $text;

    /** @var string */
    private $authorId;

    /** @var string */
    private $createdAt;

    public function __set($name, $value)
    {
        echo 'Пытаюсь задать для свойства ' . $name . ' значение ' . $value . '<br>';
    }
}

Посмотрим на результат:
Результат магического метода __set()

Видим, что этот метод был вызван по два раза для свойств author_id и created_at. Обратите внимание – мы только выводили сообщения на экран, больше мы ничего с этими данными не сделали. Поэтому теперь в самих объектах класса Article этих свойств нет.

Ещё раз о том, что же произошло. В тот момент, когда наш код с помощью PDO пытался сделать $this->created_at = что-то, вызывался метод __set() и просто выводил сообщение на экран. Давайте теперь в этом методе сделаем так, чтобы свойства снова устанавливались. Сделать это проще простого:

public function __set($name, $value)
{
    echo 'Пытаюсь задать для свойства ' . $name . ' значение ' . $value . '<br>';
    $this->$name = $value;
}

$name – имя свойства, $value – его значение. Ничего сложного. Давайте снова запустим код:

Динамическое задание свойств через магический сеттер

Видим, что эти свойства снова появились.

А теперь мы можем внутри этого метода сделать так, чтобы задавалось свойство с именем не $name, а какое-нибудь другое. Скажем, если туда передаётся $name равное ‘author_id’, то чтобы оно преобразовывалось в ‘authorId’ и мы задавали уже нужное свойство класса. Итак, задача – преобразовать строки вида string_with_smth в stringWithSmth.

Я сделал это вот так:

<?php

namespace MyProject\Models\Articles;

use MyProject\Models\Users\User;

class Article
{
    /** @var int */
    private $id;

    /** @var string */
    private $name;

    /** @var string */
    private $text;

    /** @var string */
    private $authorId;

    /** @var string */
    private $createdAt;

    public function __set($name, $value)
    {
        $camelCaseName = $this->underscoreToCamelCase($name);
        $this->$camelCaseName = $value;
    }

    private function underscoreToCamelCase(string $source): string
    {
        return lcfirst(str_replace('_', '', ucwords($source, '_')));
    }
}

Я добавил специальный метод underscoreToCamelCase() – именно он и занимается преобразованием. Вот что происходит внутри этого метода:

  1. Функция ucwords() делает первые буквы в словах большими, первым аргументом она принимает строку со словами, вторым аргументом – символ-разделитель (то, что стоит между словами). После этого строка string_with_smth преобразуется к виду String_With_Smth
  2. Функция str replace() заменяет в получившейся строке все символы ‘ ’ на пустую строку (то есть она просто убирает их из строки). После этого мы получаем строку StringWithSmth
  3. Функция lcfirst() просто делает первую букву в строке маленькой. В результате получается строка stringWithSmth. И это значение возвращается этим методом.

Таким образом, если мы передадим в этот метод строку «created_at», он вернёт нам строку «createdAt», если передадим «author_id», то он вернёт «authorId». Именно то, что нам нужно!

Так вот в методе __set() я получаю нужное мне имя для свойства объекта из имени, переданного в аргументе $name, а затем задаю в свойство с получившимся именем переданное значение.

Посмотрим теперь на результат:

Преобразование подчеркушек в camelCase

Как видим, теперь наши свойства authorId и createdAt у объектов имеют нужные значения.

Давайте сделаем геттеры для свойств id, name и text:

src/MyProject/Models/Articles/Article.php

<?php

namespace MyProject\Models\Articles;

use MyProject\Models\Users\User;

class Article
{
    /** @var int */
    private $id;

    /** @var string */
    private $name;

    /** @var string */
    private $text;

    /** @var string */
    private $authorId;

    /** @var string */
    private $createdAt;

    public function __set($name, $value)
    {
        $camelCaseName = $this->underscoreToCamelCase($name);
        $this->$camelCaseName = $value;
    }

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * @return string
     */
    public function getText(): string
    {
        return $this->text;
    }

    private function underscoreToCamelCase(string $source): string
    {
        return lcfirst(str_replace('_', '', ucwords($source, '_')));
    }
}

Теперь мы можем работать с этими объектами в коде. Например – обращаться к геттерам в шаблонах.

templates/main/main.php

<?php include __DIR__ . '/../header.php'; ?>
<?php foreach ($articles as $article): ?>
    <h2><a href="/articles/<?= $article->getId() ?>"><?= $article->getName() ?></a></h2>
    <p><?= $article->getText() ?></p>
    <hr>
<?php endforeach; ?>
<?php include __DIR__ . '/../footer.php'; ?>

И теперь снова начнём передавать наши статьи во View внутри контроллера:

src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Articles\Article;
use MyProject\Services\Db;
use MyProject\View\View;

class MainController
{
    /** @var View */
    private $view;

    /** @var Db */
    private $db;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
        $this->db = new Db();
    }

    public function main()
    {
        $articles = $this->db->query('SELECT * FROM `articles`;', [], Article::class);
        $this->view->renderHtml('main/main.php', ['articles' => $articles]);
    }
}

Посмотрим на результат:
Вывод статей-объектов

Всё прекрасно работает. А в следующих уроках мы с вами научимся добавлять объекты в базу данных.

Реализуем Active Record в PHP

Сегодня мы изучим ещё один паттерн проектирования – Active Record. Этот шаблон говорит о том, что сущность (объекты класса статьи или пользователя) сами должны управлять работой с базой данных. То есть весь остальной код, который эти сущности использует, не должен знать о базе данных. Наши контроллеры не должны работать с базой данных, получая данные и заполняя ими сущности. Они должны знать только о сущностях. Сущность сама должна позаботиться о работе с базой данных. О том, как это реализовать – читайте далее.

Для начала нужно вообще понять, как стоит работать с сущностями при помощи такой концепции. Самое простое, что мы можем реализовать – это чтение из базы данных. И мы должны сделать это, обращаясь напрямую к сущностям-объектам. То есть мы должны сказать: «Эй, Article, дай мне все статьи». Но согласитесь, глупо будет для этого создать сущности, а после этого попросить чтобы они заполнили себя данными из базы. Нам нужно сделать это как-то по другому. Например, обратиться к сущности, не создавая её, но чтобы она при этом вернула нам созданные сущности. Вспоминаем статические методы – их ведь можно вызывать, не создавая объекта. То, что нам нужно!

Давайте добавим в Article статический метод, возвращающий нам все статьи.

src/MyProject/Models/Articles/Article.php

<?php

namespace MyProject\Models\Articles;

use MyProject\Services\Db;

class Article
{
    /** @var int */
    private $id;

    /** @var string */
    private $name;

    /** @var string */
    private $text;

    /** @var string */
    private $authorId;

    /** @var string */
    private $createdAt;

    public function __set($name, $value)
    {
        $camelCaseName = $this->underscoreToCamelCase($name);
        $this->$camelCaseName = $value;
    }

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * @return string
     */
    public function getText(): string
    {
        return $this->text;
    }

    /**
     * @return Article[]
     */
    public static function findAll(): array
    {
        $db = new Db();
        return $db->query('SELECT * FROM `articles`;', [], Article::class);
    }

    private function underscoreToCamelCase(string $source): string
    {
        return lcfirst(str_replace('_', '', ucwords($source, '_')));
    }
}

Теперь, чтобы получить статьи в контроллере, нам нужно сделать следующее:

src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Articles\Article;
use MyProject\View\View;

class MainController
{
    /** @var View */
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function main()
    {
        $articles = Article::findAll();
        $this->view->renderHtml('main/main.php', ['articles' => $articles]);
    }
}

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

А теперь давайте посмотрим на код этого статического метода.

/**
 * @return Article[]
 */
public static function findAll(): array
{
    $db = new Db();
    return $db->query('SELECT * FROM `articles`;', [], Article::class);
}

Согласитесь, можно заменить Article::class на self::class – и сюда автоматически подставится класс, в котором этот метод определен. А можно заменить его и вовсе на static::class – тогда будет подставлено имя класса, у которого этот метод был вызван. В чём разница? Если мы создадим класс-наследник SuperArticle, он унаследует этот метод от родителя. Если будет использоваться self:class, то там будет значение “Article”, а если мы напишем static::class, то там уже будет значение “SuperArticle”. Это называется поздним статическим связыванием – благодаря нему мы можем писать код, который будет зависеть от класса, в котором он вызывается, а не в котором он описан .

Итак, давайте изменим этот метод:

/**
 * @return Article[]
 */
public static function findAll(): array
{
    $db = new Db();
    return $db->query('SELECT * FROM `articles`;', [], static::class);
}

А теперь давайте попробуем избавиться от зависимости от таблицы “articles”. Вынесем получение названия таблицы в отдельный метод. Вот так:

/**
 * @return Article[]
 */
public static function findAll(): array
{
    $db = new Db();
    return $db->query('SELECT * FROM `' . static::getTableName() . '`;', [], static::class);
}

private static function getTableName(): string 
{
    return 'articles';
}

А теперь внимательно посмотрите на содержимое класса Article. Не кажется ли вам, что методы findAll(), __set(), underscoreToCamelCase() можно вот хоть сейчас взять и скопировать в сущность User, и начать их использовать? Только не нужно ничего копировать, мы ведь пишем на объектно-ориентированном языке, и можем использовать наследование! Мы можем просто вынести всю эту логику в отдельный класс, а там где она нужна, просто от него наследоваться. Давайте так и поступим.

Создадим отдельный класс, реализующий всю эту логику.

src/MyProject/Models/ActiveRecordEntity.php

<?php

namespace MyProject\Models;

abstract class ActiveRecordEntity
{

}

Так как создание самого этого класса нам не нужно, то делаем его абстрактным. А теперь переносим в него универсальный код из класса Article.

<?php

namespace MyProject\Models;

use MyProject\Services\Db;

abstract class ActiveRecordEntity
{
    /** @var int */
    protected $id;

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    public function __set(string $name, $value)
    {
        $camelCaseName = $this->underscoreToCamelCase($name);
        $this->$camelCaseName = $value;
    }

    private function underscoreToCamelCase(string $source): string
    {
        return lcfirst(str_replace('_', '', ucwords($source, '_')));
    }

    /**
     * @return static[]
     */
    public static function findAll(): array
    {
        $db = new Db();
        return $db->query('SELECT * FROM `' . static::getTableName() . '`;', [], static::class);
    }

    abstract protected static function getTableName(): string;
}

Давайте по порядку.

  • добавили protected-свойство ->id и public-геттер для него – у всех наших сущностей будет id, и нет необходимости писать это каждый раз в каждой сущности – можно просто унаследовать;
  • перенесли public-метод __set() – теперь все дочерние сущности будут его иметь
  • перенесли метод underscoreToCamelCase(), так как он используется внутри метода __set()
  • public-метод findAll() будет доступен во всех классах-наследниках
  • и, наконец, мы объявили абстрактный protected static метод getTableName(), который должен вернуть строку – имя таблицы. Так как метод абстрактный, то все сущности, которые будут наследоваться от этого класса, должны будут его реализовать. Благодаря этому мы не забудем его добавить в классах-наследниках.

Давайте теперь посмотрим на то, во что у нас превратится класс Article. Наследуемся от полученного класса и убираем лишнее. Обратите внимание, свойства теперь становятся не private, а protected, чтобы к ним можно было достучаться из класса-родителя.

src/MyProject/Models/Articles/Article.php

<?php

namespace MyProject\Models\Articles;

use MyProject\Models\ActiveRecordEntity;

class Article extends ActiveRecordEntity
{
    /** @var string */
    protected $name;

    /** @var string */
    protected $text;

    /** @var string */
    protected $authorId;

    /** @var string */
    protected $createdAt;

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * @return string
     */
    public function getText(): string
    {
        return $this->text;
    }

    protected static function getTableName(): string
    {
        return 'articles';
    }
}

Вот так вот он у нас значительно упростился. Проверим, что всё работает. И… Всё работает!
Давайте теперь добавим метод, который будет возвращать одну статью по id. Проще простого! Добавляем в наш класс ActiveRecordEntity ещё один метод getById().

src/MyProject/Models/ActiveRecordEntity.php

<?php

namespace MyProject\Models;

use MyProject\Services\Db;

abstract class ActiveRecordEntity
{
    ...

    /**
     * @param int $id
     * @return static|null
     */
    public static function getById(int $id): ?self
    {
        $db = new Db();
        $entities = $db->query(
            'SELECT * FROM `' . static::getTableName() . '` WHERE id=:id;',
            [':id' => $id],
            static::class
        );
        return $entities ? $entities[0] : null;
    }
}

Этот метод вернёт либо один объект, если он найдётся в базе, либо null – что будет говорить об его отсутствии.

Тогда наш контроллер статей, где мы получаем только одну статью приведется к виду:

src/MyProject/Controllers/ArticlesController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Articles\Article;
use MyProject\View\View;

class ArticlesController
{
    /** @var View */
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function view(int $articleId)
    {
        $article = Article::getById($articleId);

        if ($article === null) {
            $this->view->renderHtml('errors/404.php', [], 404);
            return;
        }

        $this->view->renderHtml('articles/view.php', ['article' => $article]);
    }
}

А шаблон станет таким:

templates/articles/view.php

<?php include __DIR__ . '/../header.php'; ?>
    <h1><?= $article->getName() ?></h1>
    <p><?= $article->getText() ?></p>
<?php include __DIR__ . '/../footer.php'; ?>

А теперь давайте в нашем классе User добавим свойства, которые будут соответствовать его полям в базе данных.

src/MyProject/Models/Users/User.php

<?php

namespace MyProject\Models\Users;

class User
{
    /** @var string */
    protected $nickname;

    /** @var string */
    protected $email;

    /** @var int */
    protected $isConfirmed;

    /** @var string */
    protected $role;

    /** @var string */
    protected $passwordHash;

    /** @var string */
    protected $authToken;

    /** @var string */
    protected $createdAt;

    /**
     * @return string
     */
    public function getEmail(): string
    {
        return $this->email;
    }
}

А теперь внимание, просто наследуемся от нашего ActiveRecordEntity и получаем все эти возможности, что есть у сущности Article! Просто добавляем несколько строк и указываем нужную таблицу, где хранятся пользователи.

src/MyProject/Models/Users/User.php

<?php

namespace MyProject\Models\Users;

use MyProject\Models\ActiveRecordEntity;

class User extends ActiveRecordEntity
{
    /** @var string */
    protected $nickname;

    /** @var string */
    protected $email;

    /** @var int */
    protected $isConfirmed;

    /** @var string */
    protected $role;

    /** @var string */
    protected $passwordHash;

    /** @var string */
    protected $authToken;

    /** @var string */
    protected $createdAt;

    /**
     * @return string
     */
    public function getNickname(): string
    {
        return $this->nickname;
    }

    protected static function getTableName(): string
    {
        return 'users';
    }
}

Да это же магия! =)
Попробуем вывести автора статьи, для этого у статьи добавляем геттер для этого поля:

src/MyProject/Models/Articles/Article.php

<?php

namespace MyProject\Models\Articles;

use MyProject\Models\ActiveRecordEntity;

class Article extends ActiveRecordEntity
{
    ...
    /**
     * @return int
     */
    public function getAuthorId(): int
    {
        return (int) $this->authorId;
    }
}

Добавляем в контроллере получение нужного юзера:

src/MyProject/Controllers/ArticlesController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Articles\Article;
use MyProject\Models\Users\User;
use MyProject\View\View;

class ArticlesController
{
    /** @var View */
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function view(int $articleId)
    {
        $article = Article::getById($articleId);

        if ($article === null) {
            $this->view->renderHtml('errors/404.php', [], 404);
            return;
        }

        $articleAuthor = User::getById($article->getAuthorId());

        $this->view->renderHtml('articles/view.php', [
            'article' => $article,
            'author' => $articleAuthor
        ]);
    }
}

И выводим никнейм автора в шаблоне:

templates/articles/view.php

<?php include __DIR__ . '/../header.php'; ?>
    <h1><?= $article->getName() ?></h1>
    <p><?= $article->getText() ?></p>
    <p>Автор: <?= $author->getNickname() ?></p>
<?php include __DIR__ . '/../footer.php'; ?>

Смотрим на результат:

Вывод автора статьи

Круто, да? Но можно ещё круче! Можно ведь попросить статью давать нам не id автора, а сразу автора! Для этого просто меняем геттер в статье:

src/MyProject/Models/Articles/Article.php

<?php

namespace MyProject\Models\Articles;

use MyProject\Models\ActiveRecordEntity;
use MyProject\Models\Users\User;

class Article extends ActiveRecordEntity
{
    ... 
    /**
     * @return User
     */
    public function getAuthor(): User
    {
        return User::getById($this->authorId);
    }
}

Вот так просто! Прямо в геттере просим сущность юзера выполнить запрос в базу и получить нужного пользователя, по id, который хранится в статье. При этом запрос будет выполнен только если мы вызовем этот геттер, это называется LazyLoad (ленивая загрузка) – это когда данные не подгружаются до тех пор, пока их не запросят .

Код нашего контроллера снова упрощается:

src/MyProject/Controllers/ArticlesController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Articles\Article;
use MyProject\View\View;

class ArticlesController
{
    /** @var View */
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function view(int $articleId)
    {
        $article = Article::getById($articleId);

        if ($article === null) {
            $this->view->renderHtml('errors/404.php', [], 404);
            return;
        }

        $this->view->renderHtml('articles/view.php', [
            'article' => $article
        ]);
    }
}

А в шаблоне мы можем напрямую запросить пользователя:

templates/articles/view.php

<?php include __DIR__ . '/../header.php'; ?>
    <h1><?= $article->getName() ?></h1>
    <p><?= $article->getText() ?></p>
    <p>Автор: <?= $article->getAuthor()->getNickname() ?></p>
<?php include __DIR__ . '/../footer.php'; ?>

Насыщенный получился урок. Надеюсь, всё было понятно. Если нет – вы знаете, что я всегда подскажу, не стесняйтесь, обращайтесь. До следующего урока!

7 симпатий