0% прочитано

Laravel MassPrunable на практиці: як я видаляю старі запити на зміну електронної адреси

Під час реалізації зміни електронної адреси в ICanUp мені потрібно було очищати старі UserEmailChangeRequest, але не одразу після expires_at. Показую, чому я залишила 30 днів зберігання, винесла правило в prunable() і використала стандартний model:prune замість окремої команди.

29 вересня 2026 р. 10 хв читанняЛаравель

Під час реалізації зміни електронної адреси в ICanUp у мене з’явилася звичайна на перший погляд задача: старі UserEmailChangeRequest не повинні накопичуватися в базі безкінечно.

Кожен такий запит має expires_at. Після цієї дати ним уже не можна підтвердити зміну адреси. Найпростіше рішення виглядає очевидно: запит прострочився - видаляємо.

Але в реальному користувацькому сценарії це виявилися два різні моменти: коли дія перестає бути доступною і коли сам запис уже можна фізично прибрати.

У моєму випадку прострочення не означає негайне видалення. Запит зберігається ще 30 днів після expires_at і тільки тоді потрапляє під очищення.

Реальний випадок: 30 днів після expires_at

PHP - Правило очищення UserEmailChangeRequest
use Illuminate\Database\Eloquent\Builder;use Illuminate\Database\Eloquent\MassPrunable; final class UserEmailChangeRequest extends Model{    use MassPrunable;     public const RETENTION_DAYS = 30;     public function prunable(): Builder    {        return static::query()            ->where(                'expires_at',                '<=',                now()->subDays(self::RETENTION_DAYS),

Уся важлива умова тут знаходиться в prunable(). Я не шукаю всі записи, для яких expires_at <= now(). Такий запит видалив би їх одразу після завершення строку дії.

Замість цього межа зсувається на 30 днів назад: expires_at <= now() - 30 days.

Тобто Laravel може видалити тільки ті записи, які вже були прострочені щонайменше 30 днів.

Стан запиту

Що робимо

Чому

Прострочений 31 день тому

Видаляємо

30 днів зберігання вже минули

Прострочений 1 день тому

Залишаємо

Період зберігання ще триває

Строк дії ще не минув

Залишаємо

Запит ще активний

Строк дії і строк зберігання - не одне й те саме

expires_at відповідає на питання: до якого моменту цей запит можна використати для зміни електронної адреси?

Період зберігання відповідає на інше питання: коли цей запис уже не потрібний системі і його можна остаточно видалити?

Якщо звести обидва рішення до одного часу, технічне очищення починає керувати користувацьким сценарієм. Я не хотіла такої залежності, тому винесла окремі 30 днів у RETENTION_DAYS.

Поле expires_at визначає, коли дія перестає бути дійсною. Період зберігання визначає, коли дані перестають бути корисними. Це не обов’язково один і той самий момент.

Чому я не писала окрему команду очищення

BASH - Команда, яку можна було б створити окремо
php artisan app:users:cleanup-email-change-requests

Без стандартного механізму Laravel я могла б створити власну Artisan-команду, окремий сервіс, запит на видалення, запис у розклад і тести всього цього шару.

Але в цій задачі немає окремого процесу очищення. Є лише правило життєвого циклу конкретної моделі: які записи UserEmailChangeRequest вже можна видалити?

Саме на це питання і відповідає prunable(). Виконання очищення я залишаю Laravel через стандартну команду model:prune.

BASH - Очищення тільки UserEmailChangeRequest
php artisan model:prune \  --model='App\Modules\User\Domain\Entity\UserEmailChangeRequest'

Чому тут саме MassPrunable

У цьому очищенні мені не потрібно завантажувати кожен запис як окрему модель. Також немає файлів, зовнішніх ресурсів або іншої дії, яку треба виконати перед видаленням кожного UserEmailChangeRequest.

Тому я використала MassPrunable. Laravel може виконати масове видалення без створення об’єкта моделі для кожного рядка.

Для регулярного очищення простих історичних записів це саме та поведінка, яка мені потрібна.

MassPrunable не викликає pruning(), deleting або deleted для кожного запису. Якщо очищення потребує таких дій, цей варіант уже не підходить автоматично.

Цей нюанс для мене важливіший за сам MassPrunable. MassPrunable я обираю не тому, що він коротший у коді, а тому, що видалення цих записів не має окремих побічних дій.

Якби перед видаленням потрібно було очистити файл, викликати зовнішній сервіс або виконати логіку для кожної моделі, я б переглянула це рішення.

Розклад: запускаю тільки цю модель

PHP - Щоденне очищення старих запитів
use App\Modules\User\Domain\Entity\UserEmailChangeRequest;use Illuminate\Support\Facades\Schedule; Schedule::command(    'model:prune',    ['--model' => UserEmailChangeRequest::class],)    ->name('users:prune-email-change-requests')    ->dailyAt('03:40')    ->timezone((string) config('app.timezone'))    ->withoutOverlapping();

У розкладі я явно передаю --model, тому ця задача не запускає очищення всіх моделей, для яких у застосунку може бути налаштоване стандартне видалення старих записів.

Команда має власне ім’я, виконується щодня о 03:40 у часовій зоні застосунку і використовує withoutOverlapping().

Час 03:40 не є частиною правила зберігання. Це лише щоденне вікно виконання. Саме рішення про видалення все одно визначає prunable().

Запис у routes/console.php сам нічого не запускає

Тут є важливий серверний нюанс, який легко пропустити. Додати Schedule::command(...) недостатньо. На сервері повинен працювати загальний Laravel Scheduler.

У ICanUp я перевіряю це окремо після змін у розкладі. Спочатку дивлюся, чи Laravel бачить задачу через schedule:list, а потім перевіряю системний cron, який викликає schedule:run щохвилини.

Окремий cron саме для model:prune тут не потрібний.

BASH - Перевірка Laravel Scheduler
php artisan schedule:list
BASH - Загальний cron Laravel Scheduler на сервері
* * * * * /usr/bin/php /var/www/icanup/artisan schedule:run >> /var/log/icanup-scheduler.log 2>&1
Якщо schedule:run не виконується на сервері, правильний prunable() і правильний запис у розкладі не допоможуть: model:prune просто не буде запущений у потрібний час.

Я тестую не наявність MassPrunable, а межу в 30 днів

Для мене слабкий тест тут виглядав би як перевірка, що модель просто використовує MassPrunable. Це нічого не говорить про наше правило.

Помилка, якої я хочу уникнути, набагато практичніша: випадково змінити запит так, щоб видаляти всі прострочені записи одразу або, навпаки, не видаляти старі взагалі.

Тому тест охоплює три різні точки відносно межі.

  • Запит прострочений 31 день тому - має потрапити в prunable().
  • Запит прострочений 1 день тому - має залишитися.
  • Запит ще не прострочений - теж має залишитися.
PHP - Перевірка правила зберігання
$prunableIds = (new UserEmailChangeRequest())    ->prunable()    ->pluck('id')    ->all(); expect($prunableIds)    ->toContain($oldExpired->id)    ->not->toContain($recentExpired->id)    ->not->toContain($active->id);

Цей тест прив’язаний не до внутрішньої реалізації Laravel, а до нашої домовленості про дані. Я можу змінити спосіб запуску очищення, але правило 30 днів після expires_at залишиться захищеним.

Саме такі тести для мене корисніші за перевірку самої наявності MassPrunable у класі.

У підсумку кожна частина має одну відповідальність

Частина

За що відповідає

expires_at

Коли запит перестає бути дійсним

RETENTION_DAYS

Скільки ще зберігаємо прострочений запис

prunable()

Які записи вже можна видалити

model:prune

Виконує стандартне очищення Laravel

Laravel Scheduler

Запускає очищення у визначений час

Системний cron

Регулярно запускає Laravel Scheduler на сервері

Мені подобається цей поділ тим, що для очищення не з’явився ще один сервіс із назвою на кшталт CleanupExpiredEmailChangeRequestsService, який по суті повторював би можливості Laravel.

Модель зберігає своє правило життєвого циклу. Laravel знає, як виконати очищення. Розклад знає, коли його запустити. Сервер знає, як регулярно запускати сам розклад.

Коли стандартного очищення Laravel уже недостатньо

Я не вважаю MassPrunable універсальною заміною будь-якого очищення.

Окремий процес виправданий, якщо перед видаленням треба виконати доменну дію, звернутися до зовнішнього API, видалити пов’язані файли, вести окремий аудит або обробляти кожен запис індивідуально.

Але якщо задача зводиться до правила "цей рядок уже можна видалити", спочатку я перевіряю, чи достатньо стандартного механізму очищення Laravel.

Висновок

У випадку UserEmailChangeRequest мені не знадобилася окрема команда очищення. Я залишила правило там, де воно має найбільше сенсу: у самій моделі.

prunable() визначає чітку межу: запис можна видалити тільки тоді, коли після його expires_at минуло ще 30 днів. MassPrunable дозволяє Laravel прибрати такі записи масово, а Scheduler запускає це регулярно.

Найважливішим для мене виявився не сам MassPrunable, а розділення двох понять.

Поле expires_at визначає, коли дія перестає бути дійсною. Період зберігання визначає, коли дані перестають бути корисними. Це не обов’язково один і той самий момент.