Під час реалізації зміни електронної адреси в ICanUp у мене з’явилася звичайна на перший погляд задача: старі UserEmailChangeRequest не повинні накопичуватися в базі безкінечно.
Кожен такий запит має expires_at. Після цієї дати ним уже не можна підтвердити зміну адреси. Найпростіше рішення виглядає очевидно: запит прострочився - видаляємо.
Але в реальному користувацькому сценарії це виявилися два різні моменти: коли дія перестає бути доступною і коли сам запис уже можна фізично прибрати.
Реальний випадок: 30 днів після expires_at
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 визначає, коли дія перестає бути дійсною. Період зберігання визначає, коли дані перестають бути корисними. Це не обов’язково один і той самий момент.
Чому я не писала окрему команду очищення
php artisan app:users:cleanup-email-change-requestsБез стандартного механізму Laravel я могла б створити власну Artisan-команду, окремий сервіс, запит на видалення, запис у розклад і тести всього цього шару.
Але в цій задачі немає окремого процесу очищення. Є лише правило життєвого циклу конкретної моделі: які записи UserEmailChangeRequest вже можна видалити?
Саме на це питання і відповідає prunable(). Виконання очищення я залишаю Laravel через стандартну команду model:prune.
php artisan model:prune \ --model='App\Modules\User\Domain\Entity\UserEmailChangeRequest'Чому тут саме MassPrunable
У цьому очищенні мені не потрібно завантажувати кожен запис як окрему модель. Також немає файлів, зовнішніх ресурсів або іншої дії, яку треба виконати перед видаленням кожного UserEmailChangeRequest.
Тому я використала MassPrunable. Laravel може виконати масове видалення без створення об’єкта моделі для кожного рядка.
Для регулярного очищення простих історичних записів це саме та поведінка, яка мені потрібна.
Цей нюанс для мене важливіший за сам MassPrunable. MassPrunable я обираю не тому, що він коротший у коді, а тому, що видалення цих записів не має окремих побічних дій.
Якби перед видаленням потрібно було очистити файл, викликати зовнішній сервіс або виконати логіку для кожної моделі, я б переглянула це рішення.
Розклад: запускаю тільки цю модель
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 тут не потрібний.
php artisan schedule:list* * * * * /usr/bin/php /var/www/icanup/artisan schedule:run >> /var/log/icanup-scheduler.log 2>&1Я тестую не наявність MassPrunable, а межу в 30 днів
Для мене слабкий тест тут виглядав би як перевірка, що модель просто використовує MassPrunable. Це нічого не говорить про наше правило.
Помилка, якої я хочу уникнути, набагато практичніша: випадково змінити запит так, щоб видаляти всі прострочені записи одразу або, навпаки, не видаляти старі взагалі.
Тому тест охоплює три різні точки відносно межі.
- Запит прострочений 31 день тому - має потрапити в
prunable(). - Запит прострочений 1 день тому - має залишитися.
- Запит ще не прострочений - теж має залишитися.
$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 у класі.
У підсумку кожна частина має одну відповідальність
Частина | За що відповідає |
|---|---|
| Коли запит перестає бути дійсним |
| Скільки ще зберігаємо прострочений запис |
| Які записи вже можна видалити |
| Виконує стандартне очищення Laravel |
Laravel Scheduler | Запускає очищення у визначений час |
Системний cron | Регулярно запускає Laravel Scheduler на сервері |
Мені подобається цей поділ тим, що для очищення не з’явився ще один сервіс із назвою на кшталт CleanupExpiredEmailChangeRequestsService, який по суті повторював би можливості Laravel.
Модель зберігає своє правило життєвого циклу. Laravel знає, як виконати очищення. Розклад знає, коли його запустити. Сервер знає, як регулярно запускати сам розклад.
Коли стандартного очищення Laravel уже недостатньо
Я не вважаю MassPrunable універсальною заміною будь-якого очищення.
Окремий процес виправданий, якщо перед видаленням треба виконати доменну дію, звернутися до зовнішнього API, видалити пов’язані файли, вести окремий аудит або обробляти кожен запис індивідуально.
Але якщо задача зводиться до правила "цей рядок уже можна видалити", спочатку я перевіряю, чи достатньо стандартного механізму очищення Laravel.
Висновок
У випадку UserEmailChangeRequest мені не знадобилася окрема команда очищення. Я залишила правило там, де воно має найбільше сенсу: у самій моделі.
prunable() визначає чітку межу: запис можна видалити тільки тоді, коли після його expires_at минуло ще 30 днів. MassPrunable дозволяє Laravel прибрати такі записи масово, а Scheduler запускає це регулярно.
Найважливішим для мене виявився не сам MassPrunable, а розділення двох понять.
Поле expires_at визначає, коли дія перестає бути дійсною. Період зберігання визначає, коли дані перестають бути корисними. Це не обов’язково один і той самий момент.



