Комментарии и отношения Eloquent
Оглавление серии «Laravel 13 и PHP: портал «Заявка»»
Описание заявки можно редактировать, но обсуждение удобнее хранить отдельными комментариями. Добавим таблицу comments, отношения Ticket и Comment, форму заметки и предварительную загрузку авторов. Результат урока — набор связанных записей, у которых заявка и автор назначаются на сервере. Комментарий не переписывает body и сохраняется как самостоятельный элемент истории обращения.
В нашем правиле владелец может добавлять заметки и к закрытой заявке. Это осознанное отличие от редактирования основного текста: закрытие фиксирует решение обращения, но не запрещает уточнение собственного обсуждения. Если продукт требует полностью неизменный архив, правило нужно ужесточить отдельно. Не переносите условие status open на любую новую операцию автоматически только из-за наличия такого условия в update.
Таблица и отношения
Создадим полный database/migrations/2026_10_11_000200_create_comments_table.php. kind позволяет позднее различить обычную заметку и запись закрытия, при этом внешний запрос не сможет произвольно назначить этот вид.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->restrictOnDelete();
$table->string('kind', 16)->default('note');
$table->text('body');
$table->timestamps();
$table->index(['ticket_id', 'id']);
});
}
public function down(): void { Schema::dropIfExists('comments'); }
};
Ниже полные Ticket.php и новый Comment.php. HasMany отвечает на вопрос, какие строки принадлежат заявке, а BelongsTo находит родительскую запись или автора. Названия foreign key совпадают с соглашениями моделей.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Ticket extends Model
{
protected $fillable = ['title', 'body'];
public function user(): BelongsTo { return $this->belongsTo(User::class); }
public function comments(): HasMany { return $this->hasMany(Comment::class)->orderBy('id'); }
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Comment extends Model
{
protected $fillable = ['body'];
public function ticket(): BelongsTo { return $this->belongsTo(Ticket::class); }
public function user(): BelongsTo { return $this->belongsTo(User::class); }
}
Документация отношений Eloquent описывает различие метода отношения и загруженных данных. Вызов comments с круглыми скобками даёт отношение для запроса и записи, а свойство comments после загрузки даёт коллекцию. Похожий синтаксис скрывает разные возможности, поэтому полезно читать его по назначению каждой строки.
Добавление заметки
Полный StoreCommentRequest.php использует policy view: добавлять заметку может владелец доступной ему заявки. body нормализуется только при строковом входе.
<?php
namespace App\Http\Requests;
use App\Models\Ticket;
use Illuminate\Foundation\Http\FormRequest;
class StoreCommentRequest extends FormRequest
{
protected function prepareForValidation(): void
{
if (is_string($this->input('body'))) $this->merge(['body' => trim($this->input('body'))]);
}
public function authorize(): bool
{
$ticket = $this->route('ticket');
return $ticket instanceof Ticket && ($this->user()?->can('view', $ticket) ?? false);
}
public function rules(): array { return ['body' => ['required', 'string', 'max:2000']]; }
}
Ниже полный TicketCommentController.php. ticket_id выставляется отношением при save, user_id приходит из сессии, kind назначается как note.
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreCommentRequest;
use App\Models\Comment;
use App\Models\Ticket;
class TicketCommentController extends Controller
{
public function __invoke(StoreCommentRequest $request, Ticket $ticket)
{
$comment = new Comment($request->validated());
$comment->user_id = $request->user()->id;
$comment->kind = 'note';
$ticket->comments()->save($comment);
return redirect()->route('tickets.show', $ticket)->with('status', 'Заметка добавлена.');
}
}
В маршрутах добавляется POST и импорт контроллера. В TicketShowController перед return добавляется $ticket load comments.user. В архиве есть полные итоговые файлы; ниже точные фрагменты изменения.
Route::post('/tickets/{ticket}/comments', TicketCommentController::class)->whereNumber('ticket')->middleware('auth')->name('comments.store');
$ticket->load('comments.user');
К show.blade.php перед endsection добавьте следующий фрагмент. Автор и текст проходят обычное экранирование.
<h2>Обсуждение</h2>
<ul>@forelse($ticket->comments as $comment)
<li>{{ $comment->user->name }}: {{ $comment->body }}</li>
@empty<li>Заметок нет.</li>@endforelse</ul>
<form method="post" action="{{ route('comments.store', $ticket) }}">@csrf
<label>Новая заметка <textarea name="body" required maxlength="2000">{{ old('body') }}</textarea></label>
@error('body')<p role="alert">{{ $message }}</p>@enderror
<button type="submit">Добавить заметку</button>
</form>
Предварительная загрузка и количество запросов
Если шаблон получает comments, а затем читает user каждого комментария без eager loading, он способен инициировать дополнительные обращения к базе по каждой строке. load comments.user делает зависимость данных явной в контроллере. Это предотвращает конкретную форму N+1, но не означает, что любой объём обсуждения теперь дешёвый. Очень длинная история потребует ограниченной выборки комментариев и отдельной пагинации.
Внешний ключ автора запрещает его удаление, пока существуют комментарии, поэтому шаблон ожидает user. Для обезличивания учётных записей понадобится другое правило и соответствующая подпись. Не меняйте restrict на nullable только ради отсутствия исключения, не определив, как история будет показывать потерянного автора. Структура отношения должна поддерживать смысл отображаемой информации.
Полезный неправильный случай — принять ticket_id и user_id из формы, затем вызвать Comment create request all. Клиент смог бы написать от чужого автора и к чужой заявке. Наш Form Request проверяет маршрутный Ticket, контроллер связывает комментарий именно с ним, а пользователя получает из сессии. Форма содержит только body; даже добавленные извне служебные поля не используются.
Заметка и история перехода
kind note сейчас не выводится отдельной меткой, потому что все пользовательские записи одного вида. Следующий урок создаст closure вместе с изменением status в транзакции. Важно заранее отличить эту запись от произвольного body пользователя: человек не может назвать собственную заметку closure и выдать её за серверное закрытие. Право на вид истории назначается операцией приложения.
Ожидаемая новая заметка появляется в порядке id после прежних записей, а тело заявки не меняется. Повторная отправка может создать ещё одну заметку: здесь не заявлена идемпотентность обсуждения. Переход redirect уменьшает случайный повтор при обновлении страницы, но не устраняет повтор внешнего запроса. Перед продолжением объясните, какие значения назначает relation save и какие берёт контроллер. Следующий шаг свяжет две записи одной атомарной операцией закрытия.
Состав отношения и порядок обсуждения
comments объявлен с orderBy id, поэтому порядок обсуждения читается из модели. Если контроллер добавит другой порядок, следует явно определить, какой из них действует, а не рассчитывать на случайное сочетание clauses. Сохранение через отношение не означает, что комментарии уже загружены в свойство существующего Ticket. После redirect карточка выполняет новое чтение и load, поэтому пользователь получает свежую коллекцию. При немедленном возврате прежнего объекта без загрузки можно было бы увидеть старое обсуждение.
Метод user у Comment описывает автора, а метод user у Ticket — владельца. Сейчас оба часто указывают на одного человека, но предметный смысл различается. Если позже менеджер сможет оставить заметку на чужой заявке, комментарий должен сохранить его настоящую личность, а владелец Ticket не должен измениться. Совпадение исходных данных не является причиной объединить два отношения в одно без названия роли.
Cascade удаления comments вместе с Ticket отражает зависимость обсуждения от обращения. В интерфейсе нет операции удаления заявки, поэтому это структурное правило, а не разрешение пользователю стереть историю. Для рабочей архивной системы удаление может быть запрещено или заменено обезличиванием. Схема и policy снова выполняют разные задачи: база знает связь строк, приложение решает допустимость действий.
Body комментария ограничен двумя тысячами символов и не принимает файлы. Если добавить вложение прямо в этот input, оно не станет допустимым только потому, что будущая заявка поддерживает PDF. Для другого вида данных потребуется отдельное поле, транспорт multipart, проверка и частный путь. Чёткий небольшой контракт помогает не смешивать заметку, историю перехода и бинарный документ.
Наконец, экран показывает имя автора на момент чтения. Переименование User изменит эту подпись у прежних комментариев, потому что модель хранит ссылку, а не текстовый снимок. Если историческое имя принципиально, его нужно сохранять отдельно по правилам продукта. Текущее отношение не обещает неизменности всех подписей прошлого.
Для самостоятельного воспроизведения результата «Сохранить комментарий текущего пользователя» выберите lesson-09 в архиве исходников. Статус выполнения этого состояния: not_run.