Track Milestone Events with whereJsonContains()

📖 3 minutes read

Imagine you’re building a social platform where users can like posts. You want to send notifications when a post hits milestones: “Your post just hit 100 likes!”

But there’s a problem: vote counts can fluctuate. Someone might like, unlike, then like again. You don’t want to spam the author with duplicate “100 likes” notifications every time the count crosses that threshold.

Store Milestone Data in Notifications

Laravel’s database notifications store arbitrary data in a JSON column. Use this to track exactly which milestone was sent:

// In your notification class
class LikeMilestoneNotification extends Notification
{
    public function __construct(
        public Post $post,
        public int $milestone
    ) {}

    public function toArray($notifiable): array
    {
        return [
            'post_id' => $this->post->getKey(),
            'post_title' => $this->post->title,
            'milestone' => $this->milestone,
            'message' => "Your post hit {$this->milestone} likes!",
        ];
    }
}

Check for Duplicates with whereJsonContains()

Before sending a milestone notification, query the JSON data column to see if that exact milestone was already sent:

use App\Notifications\LikeMilestoneNotification;

public function handleLike($event): void
{
    $post = $event->like->post;
    
    // Load the total likes count
    $totalLikes = $post->likes()->count();

    // Check if we hit a milestone
    $milestones = [1, 5, 10, 25, 50, 100, 250, 500, 1000];
    if (!in_array($totalLikes, $milestones)) {
        return; // Not a milestone, skip
    }

    // Check if we already sent this exact milestone notification
    $alreadySent = $post->author->notifications()
        ->where('type', LikeMilestoneNotification::class)
        ->whereJsonContains('data->post_id', $post->getKey())
        ->whereJsonContains('data->milestone', $totalLikes)
        ->exists();

    if ($alreadySent) {
        return; // Already notified, skip
    }

    // Send the milestone notification
    $post->author->notify(
        new LikeMilestoneNotification($post, $totalLikes)
    );
}

Why This Works

whereJsonContains() queries the JSON data column efficiently. By storing both post_id and milestone in the notification data, you can check:

  • Did we send a milestone notification for this specific post?
  • Was it for this exact milestone number?

If the post later hits 250 likes, the check for milestone 100 will still return true (already sent), but the check for milestone 250 will return false (new milestone).

Performance Optimization with loadSum()

If you’re counting relationships frequently (like in an event listener that fires on every like), use loadSum() instead of count():

// Before: runs a separate query each time
$totalLikes = $post->likes()->count();

// After: eager load the sum
$post->loadSum('likes', 'value'); // Assuming likes have a 'value' column (1 or -1)
$totalLikes = $post->likes_sum_value ?? 0;

Laravel will cache this on the model, so subsequent accesses don’t hit the database.

Bonus: Exclude Self-Likes

If users can like their own posts, you probably don’t want to send them milestone notifications for their own activity:

// Skip if the author liked their own post
if ($post->author_id === $event->like->user_id) {
    return;
}

Database Schema

Laravel’s default notifications table already includes the JSON data column:

// Generated by: php artisan notifications:table
Schema::create('notifications', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('type');
    $table->morphs('notifiable'); // User ID and type
    $table->text('data'); // JSON column for custom data
    $table->timestamp('read_at')->nullable();
    $table->timestamps();
});

No additional columns needed — just use the data column creatively.

Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *