Модели Eloquent

Сегодня посмотрим как работать с нашими новыми таблицами, созданными в прошлом уроке.
Модели - это PHP классы, лежащие в core/src/Models, расширяющие Illuminate\Database\Eloquent\Model и представляющие собой записи в соответствующей таблице базы данных. В отличие от MODX и его xDPO, здесь не нужно писать никаких схем и генерировать непонятные map файлы. Одна модель - это всегда один класс и одна таблица в БД, всё очень просто и понятно. Если миграции меняют таблицу, то эти изменения нужно будет отразить и в модели.
Vesp устанавливает 4 модели по умолчанию: File, UserRole, User, UserToken - они отражают записи в таблицах files, user_roles, users и user_tokens соответственно.
Как видно, имена моделей чётко соотносятся с таблицами согласно правилам английского языка - и это не случайно. В Eloquent приняты определённые соглашения по многим ключевым моментам, включая имена моделей и таблиц.

Устройство моделей

Давайте посмотрим основные свойства модели на примере User:
<?php
// Наше пространство имён для моделей
namespace App\Models;

// Импорт нужных классов для удобства
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

/**
 * Здесь мы перечисляем все колонки таблицы с их типами 
 * для автодополнения IDE
 * @property int $id
 * @property string $username
 * @property string $password
 * @property int $role_id
 * @property bool $active
 * @property Carbon $created_at
 * @property Carbon $updated_at
 *
 * Здесь перечислены связи таблицы, их нельзя перезаписывать, только чтение
 * Роль у пользователя может быть только одна
 * @property-read UserRole $role
 * А вот токенов несколько, поэтому в типе указан массив моделей
 * @property-read UserToken[] $tokens
 */
class User extends Model
{
    // Основные свойства модели
    protected $table = 'users'; // имя таблицы без префикса
    protected $primaryKey = 'id'; // Имя первичного ключа
    protected $keyType = 'int'; // Тип ключа - число
    public $incrementing = true; // Ключ автоматически увеличивается на 1
    public $timestamps = true; // У модели есть колонки created_at и updated_at

    // Массив с колонками, которые можно массово менять у модели через метод ->fill()
    protected $fillable = ['username', 'password', 'role_id', 'active'];
    // Скрытые колонки, которые прячутся при распечатке модели через ->toArray()
    protected $hidden = ['password'];

    // Массив с приведением типов колонок, например
    // active станет не int как в MySQL, а именно boolean
    protected $casts = ['active' => 'boolean'];

    // Дальше связи с другими моделями, помните мы указывали ключи в миграциях? 
    // Это вот они, да.

    // Данная модель является дочерней по отношениею к UserRole
    // то есть, принадлежит (belongs) к роли
    public function role(): BelongsTo
    {
        // 2й и 3й параметр указаны для примера, 
        // Eloquent и сам прекрасно понимает, что связь role должна использовать 
        // колонку role_id, и присоединяться к id у UserRole
        return $this->belongsTo(UserRole::class, 'role_id', 'id');
    }

    // А здесь связь в обратную сторону - много токенов принадлежат одному User
    // То есть юзер имеет много (has many) токенов
    public function tokens(): HasMany
    {
        // Принадлежащими считаются все UserToken, 
        // где колонка user_id равна id модели User
        // Второй параметр здесь я тоже написал для примера
        return $this->hasMany(UserToken::class, 'user_id');
    }
}
В этом примере я соединил базововый Vesp\Models\User и расширяющий её App\Models\User для наглядности. Так же я специально вывел несколько параметров, которые обычно не пишут, потому что они работают по умолчанию. Например имя таблицы прекрасно определяется автоматически множественным числом слова user -> users.
В обычной типовой модели перичный ключ всегда id, и он всегда является числом (int). но давайте посмотрим на немного необычную модель UserToken:
<?php

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * @property string $token
 * @property int $user_id
 * @property Carbon $valid_till
 * @property string $ip
 * @property bool $active
 * @property Carbon $created_at
 * @property Carbon $updated_at
 *
 * @property-read User $user
 */
class UserToken extends Model
{
    // Первичный ключ уже не увеличивается автоматически
    public $incrementing = false;
    // Да и колонка для него не id
    protected $primaryKey = 'token';
    // А тип ключа - строка
    protected $keyType = 'string';

    // Вместо fillable, который разрешает массово обновлять колонки, указан guarded,
    // который запрещает обновлять колонки. Это значит, что все колонки, 
    // не указанные здесь, обновлять можно. 
    // Можно указывать или fillable, или guarded, оновременно - нельзя.
    protected $guarded = ['created_at', 'updated_at'];

    // В свойство колонок с датами добавлена и наша - valid_till. 
    // Это значит, что в PHP эта колонка будет экземпляром 
    // библиотеки для работы с датами Carbon
    protected $dates = ['valid_till'];

    protected $casts = ['active' => 'boolean'];

    // Каждый токен принадлежат своему User
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
Основное отличие этой модели в том, что у неё вовсе нет id, её первичный ключ - это уникальный token, который используется для авторизации пользователя в админке. Эту модель нельзя сохранить без явного указания колонки token. На следующем уроке мы поговорим о проверке прав доступа в контроллерах.

Работа с моделями

Сразу нужно опеределиться, что есть 2 способа работы: на уровне БД, когда вы строите и выполняете SQL запрос, и на уровне собственно модели - когда вы создаёте экземпляр класса модели и вызываете его методы.
Продемонстрирую разницу на примерах:
// Здесь мы генерируем и выполняем SQL запрос без получения модели
// UPDATE app_users SET fullname = 'test' WHERE id = 1;
User::query()->where('id', 1)->update(['fullname' => 'test']);

// А здесь мы получаем модель
// SELECT * FROM app_users WHERE id = 1;
// Создаём новый экземпляр класса User и забиваем его полученными значениями
if ($user = User::query()->find(1)) {
    // Потом меняем атрибут внутри класса
    $user->fullname = 'test';
    // И сохраняем данные обратно в таблицу
    // UPDATE app_users SET fullname = 'test' WHERE id = 1;
    $user->save();
}
Как вы понимаете, оба метода имеют свои преимущества и недостатки. Например, если вам нужно массово назначить active = 0 для всех моделей, созданных на прошлой неделе, то это гораздо лучше сделать первым способом через SQL запрос, нежели выбирать и сохранять все модели поштучно.
А при работе через класс модели открываются разные возможности, вроде получения связей и срабатывания событий на сохранение и удаление. Впрочем, методы работы можно и комбинировать, например используя связь модели:
// Получаем юзера с id = 1
if ($user = User::query()->find(1)) {
    // И удаляем всего его токены через SQL запрос
    // DELETE FROM app_user_tokens WHERE user_id = 1;
    $user->tokens()->delete();
}
Обратите внимание, что $user->tokens() создаёт SQL запрос, выбирающий все токены конкретного пользователя, а $user->tokens вернёт коллекцию моделей токенов:
// Получаем юзера с id = 1
if ($user = User::query()->find(1)) {
    // И удаляем всего его токены через перебор моделей UserToken
    foreach ($user->tokens as $token) {
        $token->delete();
    }
}
Надеюсь, вы видите и понимаете разницу, потому что в будущем мы будем использовать эти методы постоянно в нашей работе.
Создание моделей не должно вызывать никаких проблем, главное указать обязательные колонки. Для юзера это username, password и role_id:
$user = new User();
$user->fill([
    'username' => 'new_user',
    'password' => 'new_password',
    'role_id' => 2,
    'fullname' => 'New User',
]);
$user->save();
И вот еще один пример разницы в методах работы. Давайте посмотрим, что получится, если мы создадим юзера через SQL:
User::query()->insert([
    'username' => 'new_user2',
    'password' => 'new_password2',
    'role_id' => 2,
    'fullname' => 'New User 2',
]);
Вот результат: пароль не захэширован, колонки с датами пустые.
Это произошло потому, что мы раз мы не создавали экземпляр модели User, то и всякие хитрости, прописанные в ней, не работали. Мы просто сделали INSERT INTO app_users VALUES username = 'new_user2' ....

Заключение

Теперь вы должны чётко понимать алгоритм создания новых сущностей в системе:
  1. сначала миграция с описанием всех колонок и их связей
  2. затем модель с описанием тех же колонок и связей
Если же нам нужно изменить таблицу, то
  1. миграция с изменениями таблицы
  2. отражение изменений в модели: новые связи или колонки
И это не двойная работа, как может показаться в начале, это именно что работа на 2х разных уровнях: SQL и PHP. Миграции создают и меняют таблицы в базе данных, а модели работают с этими таблицами из вашего кода.
Многие сейчас наверное задумались, что можно же автоматизировать здесь что-то, например генерировать модели по готовым таблицам? Я тоже так думал, но подходящих решений не нашёл. Если они и есть, то привязаны ко всему Laravel, а не только Eloquent.
А учитывая, что в наших моделях могут быть не только колонки и связи, а еще и собственные функции - смысл в подобной автоматизации теряется.
В той же модели User, например, обязательно хэшируется пароль:
public function setAttribute($key, $value)
{
    if ($key === 'password') {
        $value = password_hash($value, PASSWORD_DEFAULT);
    }

    return parent::setAttribute($key, $value);
}
Более подробно про работу с Eloquent можно почитать в документации - там много интересного, библиотека не зря стала фактическим стандартом работы с БД в PHP. Мы же будем постоянно её использовать в наших контроллерах, о которых я расскажу на следующием уроке.

18 комментариев

Кирилл Дворянинов
Немного трудновато, а будет показан весь этот цикл с начала и до конечного посева по шагам? (Написали миграцию, потом модель и т.п.) Хотя бы коротко, как для тупых :)
Василий Наумкин
Да, конечно.
Пока общая теория, дальше будем писать своё - и всё станет проще.
Кирилл Дворянинов
Отлично! Буду ждать новых уроков с нетерпением. )
Василий Наумкин
Стараюсь писать почаще - мне и самому интересно рассказывать!
Александр Наумов
С каждым уроком все интереснее и интереснее, как в хорошем детективе, хочется узнать, как пазл сложится. Помимо полезных знаний еще приятное чтиво получается.
Василий Наумкин
Очень рад, что тебе нравится -)
Начал пытаться делать свое на основе изученного и везде пока торможу. Создаю через миграцию самую простую таблицу Создаю модель, пытаюсь засеять ошибка (
Василий Наумкин
Если ты используешь сокращённое имя модели, то в начале файла должено быть use App\Models\Post;
PhpStorm не просто так выделяет модель жёлтым цветом, а потому что видит эту ошибку. Еще советую поднять версию PHP в проекте, чтобы он не подчёркивал : void у функций.
Понятно. У меня еще много будет вопросов. Хочу два сайта с MODX перенести на VESP.
Как правильно поменять стандартный адрес админки?
Василий Наумкин
Поискать в исходниках ссылки на её адрес и поменять - скорее всего только nuxt.config.js.
А зачем это надо? Если речь про безопасность, то не нужно прятать, нужно защищать через web-сервер, потому что любой адрес админки найдут очень быстро, простым перебором.
Как я понял, пример с добавлением нового пользователя приведен чисто в демонстрационных целях. После того, как я в файле core/src/Models/User.php добавил конструкцию:
$user = new User();
$user->fill([
    'username' => 'new_user',
    'password' => 'new_password',
    'role_id' => 2,
    'fullname' => 'New User',
]);
$user->save();
и после это выполнил composer db:migrate, composer db:seed - у меня также в БД появился new_user . Правда после этого я не смог зайти в админку, система выдала кучу ошибок и пришлось откатить миграцию))
Василий Наумкин
Текст ошибки ты читать не планируешь, правильно понимаю?
Там почему-то вместо авторизации идёт создание нового юзера и ошибка говорит о том, что такой юзер уже есть.
Даже представить не могу, как вместо авторизации у тебя получился запрос на создание нового юзера.
Очевидно, что я делаю что-то неправильно, учитывая, что я в коде пока слабовато разбираюсь.) Я добавил нового пользователя, при помощи конструктора (так это вроде называется) $user = new User(); И сделал это прямо в файл core/src/Models/User.php - не знаю насколько это правильно. Дальше, чтобы новый пользователь появился в базе я либо:
  1. Просто авторизовался в админке. После этого новый пользователь появлялся в базе, но повторно зайти в админку не получалось - появлялись эти ошибки. Но если в файле core/src/Models/User.php удалить только что добавленный конструктор - $user = new User(); - ошибки пропадают, пользователь в базе сохраняется и все работает.

  2. Либо выполнял команды composer db:migrate, composer db:seed - после этого пользователь также появлялся в базе и зайти в админку сразу не получается. Опять же удаление добавленного в файле core/src/Models/User.php кода приводит к тому, что ошибки пропадают и все работает потом.

Василий Наумкин
Очевидно, что я делаю что-то неправильно
Очевидно, да.
Где-то в моих заметках написано, что нужно писать произвольный код в модели юзера, после класса? Это настолько неправильно, что у меня даже слов цензурных нет.
Ты когда пишешь свой код внутри модели, он выполняется каждый раз при запуске модели. При любом действии с юзерами происходит попытка создать одного и того же нового юзера.
Либо выполнял команды composer db:migrate, composer db:seed
Только так, да - создание новых юзеров через db:seed.
после этого пользователь также появлялся в базе и зайти в админку сразу не получается.
Почему?
Создание моделей не должно вызывать никаких проблем, главное указать обязательные колонки. Для юзера это username, password и role_id:
Наверное проще было не утомлять тебя своими изысками и ошибками, а просто спросить, как правильно использовать пример приведенного тобой кода, для создания новой модели и нового пользователя. Т.е. где должен запускаться этот код, если использовать его внутри модели не правильно?
$user = new User();
$user->fill([
    'username' => 'new_user',
    'password' => 'new_password',
    'role_id' => 2,
    'fullname' => 'New User',
]);
$user->save();
Василий Наумкин
Т.е. где должен запускаться этот код, если использовать его внутри модели не правильно?
В сиде. Или в каком-то любом другом логически обоснованном месте, но не в модели точно.
Ты почитай про ORM что ли, модель - это отражение записи в БД. Нельзя в одной записи БД создавать другую, это какая-то извращённая рекурсия.
Спасибо большое, почитаю.
bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Александр Наумов
23.07.2024, 00:20:37
Василий, спасибо большое!!
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!