<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SavedSearch extends Model
{
protected $fillable = ['user_id', 'name', 'criteria', 'is_active'];
protected $casts = [
'criteria' => 'array',
'is_active' => 'boolean',
'last_notified_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeDueForDigest(Builder $query): Builder
{
return $query->where('is_active', true)
->whereHas('user', function (Builder $q) {
$q->where('wants_search_alerts', true);
});
}
public function matchingListings()
{
$c = $this->criteria;
return Listing::query()
->where('status', 'published')
->when($this->last_notified_at, fn ($q) => $q->where('created_at', '>', $this->last_notified_at))
->when($c['keyword'] ?? null, fn ($q, $kw) => $q->where('title', 'like', "%{$kw}%"))
->when($c['category_id'] ?? null, fn ($q, $id) => $q->where('category_id', $id))
->when($c['min_price'] ?? null, fn ($q, $p) => $q->where('price_cents', '>=', $p))
->when($c['max_price'] ?? null, fn ($q, $p) => $q->where('price_cents', '<=', $p))
->orderByDesc('created_at')
->limit(25);
}
}
<?php
namespace App\Console\Commands;
use App\Jobs\SendSearchDigest;
use App\Models\SavedSearch;
use Illuminate\Console\Command;
class SendSavedSearchDigests extends Command
{
protected $signature = 'saved-searches:digest';
protected $description = 'Queue digest emails for saved searches with new matching listings';
public function handle(): int
{
$queued = 0;
SavedSearch::dueForDigest()->chunkById(200, function ($searches) use (&$queued) {
foreach ($searches as $search) {
if ($search->matchingListings()->exists()) {
SendSearchDigest::dispatch($search->id);
$queued++;
}
}
});
$this->info("Queued {$queued} saved-search digest(s).");
return self::SUCCESS;
}
}
<?php
namespace App\Jobs;
use App\Mail\SavedSearchDigest;
use App\Models\SavedSearch;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendSearchDigest implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [30, 120, 300];
public function __construct(public int $savedSearchId)
{
}
public function middleware(): array
{
return [(new WithoutOverlapping((string) $this->savedSearchId))->expireAfter(180)];
}
public function handle(): void
{
$search = SavedSearch::with('user')->find($this->savedSearchId);
if (! $search || ! $search->is_active) {
return;
}
$listings = $search->matchingListings()->get();
if ($listings->isEmpty()) {
return;
}
Mail::to($search->user->email)->send(new SavedSearchDigest($search, $listings));
// Advance the watermark only after a successful send.
$search->forceFill(['last_notified_at' => $listings->max('created_at')])->save();
}
}
This snippet shows how a marketplace can notify users about new listings that match their saved searches, batching results into a single queued digest email instead of spamming one message per match. The design separates three concerns across the tabs: an Eloquent model that knows how to evaluate its own criteria, a scheduled command that dispatches work in bulk, and a queued job that assembles and sends each digest.
In SavedSearch model, each saved search stores its filters in a JSON criteria column cast to an array, plus a last_notified_at timestamp that acts as the high-water mark for what the user has already seen. The matchingListings() method builds a query dynamically from the stored filters — keyword, price range, and category — and, crucially, only pulls listings created after last_notified_at. That timestamp is what makes the feature idempotent: a listing is never included in two digests, even if the job runs twice. The scopeDueForDigest query scope filters to active searches whose owners opted into notifications, keeping the dispatch query cheap.
In SendSavedSearchDigests command, the scheduler-driven command walks eligible searches with chunkById, which paginates by primary key so memory stays flat even with millions of rows. For each search it computes matches, skips ones with nothing new, and otherwise fires SendSearchDigest onto the queue. Doing the heavy lifting in a job rather than inline keeps the command fast and lets failed sends retry independently.
In SendSearchDigest job, ShouldQueue puts the work on a worker, $tries and $backoff give it bounded retries with a delay, and WithoutOverlapping prevents the same search from being processed concurrently. The job re-evaluates matchingListings() at send time so the email reflects current data, sends the SavedSearchDigest mailable, and then advances last_notified_at to the newest listing's timestamp. Updating the watermark only after a successful send means a crash mid-send simply reprocesses the same listings next time rather than losing them.
The main pitfall to watch is the read-then-update window: because the watermark advances to the max listing time seen, listings created during processing are captured on the following run. This pattern fits any "tell me when something new matches" feature — job alerts, price drops, inventory watches — where reliability and no-duplicate delivery matter more than instant push.
Related snips
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
module EmailNormalization
extend ActiveSupport::Concern
included do
attr_accessor :soft_warnings
Soft Validation: Normalize + Validate Email
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
class CreateDeadJobs < ActiveRecord::Migration[7.1]
def change
create_table :dead_jobs do |t|
t.string :jid, null: false
t.string :queue, null: false
t.string :klass, null: false
Background Job Dead Letter Queue (DLQ) Table
class PostsController < ApplicationController
def index
posts = Post.for_feed.page(params[:page]).per(25)
render json: {
data: posts.map { |post| PostSerializer.new(post).as_json },
N+1 Proof Serialization with preloaded associations
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Laravel database migrations for schema management
Share this code
Here's the card — post it anywhere.