DEV3 Technologies home

Dynamic QR redirect with scan stats

A short-code redirect route that looks up a cached destination and counts scans per day with one atomic upsert after the response is sent.

LaravelAPIsredirectanalyticsupsertcache

Code

<?php

namespace App\Http\Controllers;

use App\Models\QrCode;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

class QrRedirectController extends Controller
{
    // Route::get('/q/{code}', QrRedirectController::class);
    public function __invoke(Request $request, string $code)
    {
        // Short codes are cached so a busy code never hits the database for lookups.
        $qr = Cache::remember("qr:{$code}", 300, fn () => QrCode::where('code', $code)
            ->where('active', true)->first(['id', 'destination']));

        abort_if($qr === null, 404);

        $device = $this->device((string) $request->userAgent());

        // Runs after the redirect has been sent, so counting never slows the scan.
        defer(fn () => DB::table('qr_scan_daily')->upsert(
            [['qr_id' => $qr->id, 'day' => now()->toDateString(), 'device' => $device, 'scans' => 1]],
            ['qr_id', 'day', 'device'],
            ['scans' => DB::raw('scans + 1')],
        ));

        // 302, not 301: browsers must not cache it, or edits and counts would be skipped.
        return redirect()->away($qr->destination, 302)->header('Cache-Control', 'no-store');
    }

    private function device(string $ua): string
    {
        return match (true) {
            (bool) preg_match('/iphone|ipad|ios/i', $ua) => 'ios',
            (bool) preg_match('/android/i', $ua) => 'android',
            (bool) preg_match('/bot|crawl|spider|preview/i', $ua) => 'bot',
            default => 'other',
        };
    }
}

How it works

What it solves

A "dynamic" QR code encodes a short URL on your domain, not the final address. That lets you change the destination later and count how often it is scanned. The two risks are slowing the scan and losing counts under load. This controller handles both.

How it works

The code is looked up with Cache::remember for five minutes, selecting only the id and destination, so the typical scan costs a cache read. An unknown or inactive code returns 404.

Counting uses a table whose primary key is the code, day and device class. A single upsert() inserts a row with one scan, or when it exists runs scans = scans + 1 inside the database. Because the increment is computed by the database, concurrent scans never overwrite each other and no read-then-write race exists. The call is wrapped in Laravel's defer(), so it runs after the response has been sent to the phone.

The redirect uses status 302 with no-store. A 301 would be cached by browsers, which would skip your counter and ignore later destination changes.

Adapting it

  • Forget the cache key when a destination is edited: Cache::forget("qr:{$code}").
  • Add country or hour columns to the key for richer reports.
  • Add a rate limiter on the route to blunt scan inflation.

Pitfalls

  • Validate destinations when saving; redirect()->away() will follow any URL, so block unsafe schemes.
  • Prefetchers and link previewers also hit the URL, which is why bots get their own bucket.

How to use it

  1. Copy the migration and run php artisan migrate.
  2. Create a QrCode model with code, destination and active columns.
  3. Put the controller in app/Http/Controllers/ and register Route::get('/q/{code}', QrRedirectController::class);.
  4. Encode https://your-domain/q/<code> in the printed QR.
  5. Read stats: DB::table('qr_scan_daily')->where('qr_id', $id)->sum('scans').

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets