Scheduled Jobs for Business Logic: Automatic Expiration and Reminder Systems
Some business rules can't wait for user interaction. Equipment rentals expire after 14 days. Reminders need to go out 2 days before deadlines. Inactive accounts should be flagged automatically.
Laravel's task scheduler handles these time-based rules elegantly. Instead of building a separate cron infrastructure, you define scheduled tasks in PHP and let a single cron entry run them.
This post walks through two related scheduled jobs: one that sends reminders before expiration, and one that actually expires rentals when the deadline passes.
The Business Rule
Consider this workflow: when equipment is sent out for a rental, the customer has 14 days to return it. If they don't:
- At day 12, send a reminder that the return deadline is approaching
- At day 14, mark the rental as overdue and notify relevant parties
Both actions happen automatically, without any user triggering them.
Tracking When States Changed
The challenge is knowing when the 14-day clock started. If you only store the current status, you don't know when it changed.
The Right Way: Dedicated Timestamp Columns
While it might seem convenient to derive timing from activity logs, that approach has significant problems:
- Fragile string matching: Log messages can change format, breaking your queries
- Performance issues: Full-text searches on log content don't scale
- Unclear intent: Business logic buried in observability data
- Missed runs: If the scheduled job misses a day,
== 14matching fails
Instead, use dedicated timestamp columns that explicitly track business events:
// Migration
Schema::table('rentals', function (Blueprint $table) {
$table->timestamp('rented_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('reminder_sent_at')->nullable();
$table->index('expires_at'); // Index for efficient queries
});
Set these timestamps when the status changes:
$rental->status = RentalStatus::RENTED->value;
$rental->rented_at = now();
$rental->expires_at = now()->addDays(14);
$rental->save();
Now your scheduled jobs can query efficiently with indexed columns, and the business intent is crystal clear.
The Expiration Command
Create an Artisan command that finds and expires overdue rentals:
<?php
namespace App\Console\Commands;
use App\Enums\RentalStatus;
use App\Models\Rental;
use App\Notifications\Rental\RentalOverdueNotification;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Notification;
class ExpireRentals extends Command
{
protected $signature = 'rentals:expire';
protected $description = 'Marks rentals as overdue when the return deadline has passed.';
public function handle(): int
{
// Find all rentals that are still marked as rented but have passed their expiration date
// Using whereDate with <= ensures we catch any rentals that expired today or earlier
$overdueRentals = Rental::where('status', RentalStatus::RENTED->value)
->whereNotNull('expires_at')
->whereDate('expires_at', '<=', now())
->get();
$expiredCount = 0;
foreach ($overdueRentals as $rental) {
// Notify the customer who rented the equipment
$rental->customer->notify(new RentalOverdueNotification($rental));
// Also notify staff members who manage this rental
foreach ($rental->assignedStaff as $staff) {
$staff->notify(new RentalOverdueNotification($rental));
}
// Update status
$rental->status = RentalStatus::OVERDUE->value;
$rental->save();
$expiredCount++;
}
$this->info("{$expiredCount} rentals marked as overdue.");
return Command::SUCCESS;
}
}
Key improvements:
Robust date matching. Using whereDate('expires_at', '<=', now()) catches all overdue rentals, even if the job missed running yesterday. No fragile == 14 exact matching.
Indexed queries. The expires_at column is indexed, so this query scales efficiently even with thousands of rentals.
Clear business logic. The timestamps explicitly track when rentals were sent out and when they expire. No need to parse log messages.
The Reminder Command
A similar command sends warnings before expiration:
<?php
namespace App\Console\Commands;
use App\Enums\RentalStatus;
use App\Models\Rental;
use App\Notifications\Rental\RentalExpiringSoonNotification;
use Illuminate\Console\Command;
class SendRentalReminders extends Command
{
protected $signature = 'rentals:send-reminders';
protected $description = 'Sends reminder emails for rentals expiring in 2 days.';
public function handle(): int
{
// Find rentals that expire in 2 days and haven't been reminded yet
$twoDaysFromNow = now()->addDays(2)->startOfDay();
$endOfTwoDays = now()->addDays(2)->endOfDay();
$rentalsToRemind = Rental::where('status', RentalStatus::RENTED->value)
->whereNotNull('expires_at')
->whereBetween('expires_at', [$twoDaysFromNow, $endOfTwoDays])
->whereNull('reminder_sent_at') // Haven't sent a reminder yet
->get();
$reminderCount = 0;
foreach ($rentalsToRemind as $rental) {
$rental->customer->notify(new RentalExpiringSoonNotification($rental));
// Mark that we've sent the reminder
$rental->reminder_sent_at = now();
$rental->save();
$reminderCount++;
}
$this->info("{$reminderCount} reminders sent.");
return Command::SUCCESS;
}
}
The reminder_sent_at column ensures idempotency - even if the job runs multiple times on the same day, each rental only gets one reminder.
Scheduling the Commands
In Laravel 12, scheduled tasks are defined in routes/console.php rather than the Kernel class:
<?php
use Illuminate\Support\Facades\Schedule;
// Check for expiring rentals every hour
Schedule::command('rentals:send-reminders')->hourly();
Schedule::command('rentals:expire')->hourly();
Hourly runs ensure you catch expirations within a reasonable window. For most business applications, checking every hour is frequent enough without being wasteful.
The Cron Entry
On your server, add one cron entry:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
This runs every minute, but Laravel's scheduler only executes commands when their scheduled time matches. The hourly() commands run at the top of each hour.
Displaying Time Remaining
Show users how long they have until expiration:
// Rental.php
public function getDaysUntilExpiry(): int
{
if (!$this->expires_at) {
return 0;
}
$daysRemaining = now()->diffInDays($this->expires_at, false);
return max(0, $daysRemaining);
}
In Blade:
@if($rental->status == RentalStatus::RENTED->value && $rental->expires_at)
<p class="text-sm text-orange-600">
Due back in {{ $rental->getDaysUntilExpiry() }} days
</p>
@endif
Setting Timestamps When Status Changes
When a rental's status changes, set the appropriate timestamps:
// In your controller or service
public function markAsRented(Rental $rental, int $days = 14): void
{
$rental->status = RentalStatus::RENTED->value;
$rental->rented_at = now();
$rental->expires_at = now()->addDays($days);
$rental->reminder_sent_at = null; // Reset reminder flag
$rental->save();
// Still log the status change for audit purposes
activity()
->performedOn($rental)
->log("Rental marked as rented, expires {$rental->expires_at->format('Y-m-d')}");
}
This keeps your audit trail intact while using explicit timestamps for business logic.
Testing Scheduled Commands
Test your commands with specific dates:
use App\Enums\RentalStatus;
use App\Models\Rental;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('marks rentals as overdue after expiration date', function () {
$rental = Rental::factory()->create([
'status' => RentalStatus::RENTED->value,
'expires_at' => now()->subDay(), // Expired yesterday
]);
$this->artisan('rentals:expire')->assertSuccessful();
$rental->refresh();
expect($rental->status)->toBe(RentalStatus::OVERDUE->value);
});
it('does not expire rentals before expiration date', function () {
$rental = Rental::factory()->create([
'status' => RentalStatus::RENTED->value,
'expires_at' => now()->addDay(), // Expires tomorrow
]);
$this->artisan('rentals:expire')->assertSuccessful();
$rental->refresh();
expect($rental->status)->toBe(RentalStatus::RENTED->value);
});
it('sends reminders for rentals expiring in 2 days', function () {
$rental = Rental::factory()->create([
'status' => RentalStatus::RENTED->value,
'expires_at' => now()->addDays(2),
'reminder_sent_at' => null,
]);
Notification::fake();
$this->artisan('rentals:send-reminders')->assertSuccessful();
Notification::assertSentTo(
$rental->customer,
RentalExpiringSoonNotification::class
);
$rental->refresh();
expect($rental->reminder_sent_at)->not->toBeNull();
});
it('does not send duplicate reminders', function () {
$rental = Rental::factory()->create([
'status' => RentalStatus::RENTED->value,
'expires_at' => now()->addDays(2),
'reminder_sent_at' => now()->subHour(), // Already reminded
]);
Notification::fake();
$this->artisan('rentals:send-reminders')->assertSuccessful();
Notification::assertNothingSent();
});
Monitoring
Add logging to track job execution:
public function handle(): int
{
\Log::info('Starting rental expiration check');
$overdueRentals = Rental::where('status', RentalStatus::RENTED->value)
->whereNotNull('expires_at')
->whereDate('expires_at', '<=', now())
->get();
$expiredCount = 0;
foreach ($overdueRentals as $rental) {
// ... expiration logic ...
$expiredCount++;
}
\Log::info("Expired {$expiredCount} rentals");
return Command::SUCCESS;
}
Consider using Laravel's failed job handling for critical scheduled tasks:
Schedule::command('rentals:expire')
->hourly()
->onFailure(function () {
// Alert ops team
\Log::error('Rental expiration job failed');
});
Why Not Use Activity Logs?
You might wonder: if you're already logging status changes, why not use those logs to derive timing?
Activity logs are for observability, not business logic. They answer "what happened and when?" for debugging and auditing. But using them as the source of truth for business rules creates several problems:
- Fragile string matching: If log message format changes, your scheduled jobs silently break
- Performance degradation: Full-text searches on log content don't scale and can't be efficiently indexed
- Unclear intent: Business rules should be explicit in your schema, not implicit in log messages
- Missed runs: If a scheduled job misses a day, exact day matching (
== 14) fails
Use logs for auditing, timestamps for business logic. You can still log status changes for audit purposes, but use dedicated columns for time-based business rules.
Conclusion
Scheduled jobs turn time-based business rules into code. Instead of relying on users to remember deadlines, the system enforces them automatically.
The pattern is straightforward: use dedicated timestamp columns to track when business events occur, then query for items that meet your time-based criteria. This approach is robust, scalable, and makes your business logic explicit.
Start with your most important time-based rules - expiration, reminders, cleanup - and add more as needed. Laravel's scheduler makes it easy to define when jobs run, and Artisan commands make them testable and debuggable.
Your users will appreciate the proactive communication, and your business rules will be enforced consistently, 24/7.