
Выводим историю действий пользователей
15.06.2019 09:39 | Laravel
Реализуем на Laravel 5 вывод истории действий пользователей. Например, заходим на страницу профиля конкретного пользователя и видим, когда человек опубликовал статью, ответил на комментарий, лайкнул такой-то контент и т.д. (по материалам laracasts)
Нужно понимать, что запись каждого действия должна быть привязана к конкретному пользователю (один ко многим), и в то же время мы не должны зависеть от типа контента, над которым производится действие - используем полиморфные отношения один ко многим. Создадим модель и миграцию:
php artisan make:model Activity -m
Отредактируем созданную миграцию (файл database/migrations/***_create_activities_table.php
):
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateActivitiesTable extends Migration
{
public function up()
{
Schema::create('activities', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id')->index();
$table->unsignedBigInteger('subject_id')->index();
$table->string('subject_type', 50);
$table->string('type', 50);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('activities');
}
}
и модель:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Activity extends Model
{
protected $guarded = [];
/**
* Get the subject for the activity
*
* @return MorphTo
*/
public function subject(): MorphTo
{
return $this->morphTo();
}
}
не забудем добавить определение отношений в модель пользователя (app/User.php
):
<?php
...
class User extends Authenticatable
{
...
/**
* Get the activities for the user
*
* @return HasMany
*/
public function activities(): HasMany
{
return $this->hasMany(Activity::class);
}
}
Как и когда записывать действия
Когда пользователь совершает какое-либо действие с тем или иным контентом, laravel вызывает соответствующее событие, как-то created
, updated
, deleted
- воспользуемся этим, и запишем в бд, что и с чем было сделано. Опять-таки, мы не хотим дублировать код в каждой модели, потому просто напишем трейт:
<?php
namespace App\Traits;
use App\Activity;
use ReflectionClass;
use ReflectionException;
trait RecordsActivity
{
/**
* Boot records activity
*
* @return void
*/
protected static function bootRecordsActivity(): void
{
if (auth()->guest()) {
return;
}
foreach (static::getActivitiesToRecord() as $event) {
static::$event(function ($model) use ($event) {
$model->recordActivity($event);
});
}
static::deleting(function ($model) {
$model->activity()->delete();
});
}
/**
* Get events to record for the current model
*
* @return array
*/
public static function getActivitiesToRecord(): array
{
return ['created'];
}
/**
* Get the activities for the given model
*
* @return mixed
*/
public function activity()
{
return $this->morphMany(Activity::class, 'subject');
}
/**
* Record event data to db
*
* @param string $event
*
* @throws ReflectionException
*
* @return void
*/
protected function recordActivity(string $event): void
{
$this->activity()->create([
'user_id' => auth()->id(),
'type' => $this->getActivityType($event),
]);
}
/**
* Get activity type dynamically
*
* @param string $event
*
* @throws ReflectionException
*
* @return string
*/
protected function getActivityType(string $event): string
{
return $event . '_' . strtolower((new ReflectionClass($this))->getShortName());
}
}
Пояснения:
- метод
bootRecordsActivity
будет вызываться при загрузке модели, аналогично методуboot
. При этом должно быть соблюдены соглашения об именовании. Как несложно догадаться, данный метод должен начинаться со словаboot
за которым следует название трейта. - мы не знаем к каким моделям будет подключен трейт, а потому используем позднее статическое связывание
- для записи нам нужен
id
пользователя, потому не будем реагировать на действия "гостей" (не аутентифицированных) - обратите внимание на отдельную обработку события
deleting
: при удалении основной записи удаляются и записи о действиях. В противном случае в таблице activities останутся ссылки на какой-то контент, которого не существует. Это самый простой вариант. Никто не мешает прикрутить какую-то другую логику - метод
getActivitiesToRecord
содержит события, на которые будем реагировать. Если, например, хотите отслеживать обновление/редактирование, просто добавьте в массивupdated
.
Теперь можно подключить трейт в интересующих нас моделях. Например:
<?php
namespace App;
use App\Traits\RecordsActivity;
class Comment extends Model
{
use RecordsActivity;
...
}
Как выводить записанную информацию
Вернёмся в модель Activity
и добавим статический метод feed
. Для лучшего восприятия отсортируем от последних к первым, также сгруппируем записи по дням (как в Slack, Telegram и т.д.), и добавим количество записей на запрос:
/**
* Get the activity feed for the given user
*
* @param User $user
* @param int $take
*
* @return mixed
*/
public static function feed(User $user, int $take = 24)
{
return static::where('user_id', $user->id)
->latest()
->with('subject')
->take($take)
->get()
->groupBy(function ($activity) {
return $activity->created_at->format('Y-m-d');
});
}
Пример использования в котроллере:
<?php
namespace App\Http\Controllers;
use App\Activity;
use App\User;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Show a user's profile
*
* @param User $user
*
* @return View
*/
public function show(User $user): View
{
return view('profiles.show', [
'profileUser' => $user,
'activities' => Activity::feed($user)
]);
}
}
И последнее. Вполне вероятно, что контент в истории будет самый разный, и создать какое-то универсальное вью не получится. Потребуется отдельное вью для каждого типа контента. Но не делайте "простыню" if-else а-ля "если это статья - показать то-то, если комментарий - показать то-то" и т.д. - такие вещи дурно пахнут. В нашем случае с выводом по датам можно поступить, например, так:
@foreach ($activities as $date => $activity)
<h3 class="page-header">{{ $date }}</h3>
@foreach ($activity as $record)
@if (view()->exists('profiles.activities.'.$record->type))
@include("profiles.activities.{$record->type}", ['activity' => $record])
@endif
@endforeach
@endforeach
что, как говорилось выше, предполагает наличие в директории resources/views/profiles/activities соответствующих представлений (created_post.blade.php
, created_comment.blade.php
и т.д.)
На этом всё. Удачи!