<p align="center"><strong>The most complete self-hosted link management plugin for Laravel & Filament.</strong><br>Short URLs · QR codes · Analytics · SEO & OG Meta · Link Cloaking · Targeting · Deep links · Webhooks · REST API</p>
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/v/janczakb/filament-short-url.svg?style=flat-square" alt="Latest Version on Packagist"></a>
A self-hosted **URL shortener, redirect engine, and QR code manager** built as a [Filament v5](https://filamentphp.com) plugin. Drop it into any Laravel 11+ application to replace Bitly, Dub.co, or Rebrandly subscriptions — with full data ownership, no click limits, and no monthly fees.
On top of basic link shortening it ships: per-link **SEO & Open Graph meta**, **Search Engine Indexing**, and **Link Cloaking**; multi-channel analytics with live activity feed, cross-filtering, and cross-domain retargeting pixels; advanced routing rules (device, country, language, A/B); mobile deep linking into 24+ native apps; a REST API with scoped keys; HMAC-signed webhooks; and offline GDPR-safe Geo-IP — all managed from a polished Filament admin panel.
- 🌐 **Custom Domain Branding** — Register your own domains, verify DNS in real-time (multi A-record + CNAME matching), enforce verification before activation, and serve links directly from the domain root — no `/s/` prefix needed.
- 🔎 **SEO & Open Graph Meta** — Per-link `og:title`, `og:description`, and `og:image` with live social preview in Filament. Auto-scrape metadata from the destination URL, import OG images to storage, and serve bot-safe preview pages to Facebook, LinkedIn, X, Slack, and other crawlers.
- 🎭 **Link Cloaking** — Mask the destination URL in the browser address bar by embedding the target in an iframe on your short-link domain. Social bots still receive your custom OG tags; human visitors stay on your branded URL.
- 🚫 **Search Engine Indexing Control** — Per-link toggle to allow or block search engine indexing. When disabled, every redirect and interstitial response sends `X-Robots-Tag: noindex, nofollow` plus matching `<meta name="robots">` tags.
- 🌍 **Multiple Geo-IP Drivers** — Resolve visitor countries from CDN headers (Cloudflare, CloudFront), offline MaxMind, or ip-api.com. The **Headers** driver auto-enables CDN trust on save and falls back to MaxMind → ip-api when edge headers are missing (ip-api rate-limited to protect the free tier).
- ⚡ **Live Activity Feed** — Real-time visit stream on each link's stats page (`/{record}/stats/live`). Browser uses **SSE + `EventSource`** (not Livewire `wire:poll`). The server **auto-detects** your cache stack and picks the fastest transport — see [Live Activity Feed](#live-activity-feed-sse).
- 🚀 **Production Redis mode** — Set **Queue Connection = `redis`** in Settings for async visit jobs plus dedicated Redis counters, today/hourly stats, and live feed — independent of `CACHE_STORE`. Configure Redis host/port/password in the panel; **Test Redis** and **Test queue worker** buttons validate before save.
- 🛡️ **VPN, Proxy & Bot Filtering** — Optional VPN/proxy/Tor detection (flag or block) when enabled in **Settings → Advanced**. Known bot and social-preview crawlers are excluded from visit logs entirely (not logged or counted).
- 🎨 **SVG QR Code Designer** — Full dot style, gradient, margin, and logo customization. Export as SVG or high-resolution PNG directly from the admin panel (client-side via QRCodeStyling — colors, gradients, and logo are preserved).
- 📊 **QR Scan Tracking** — QR scans are recorded separately from regular web clicks via `?source=qr` or `?qr=1`. Shown as a distinct metric in analytics.
- 🚀 **Fast Redirects** — The main `/s/{key}` route skips Laravel's `web` middleware (no session/cookies on every click). Password-protected links use a separate stateful `/s-auth/{key}` route. Simple redirects stay lean; HTML interstitials (warning page, pixels, cloaking) add latency by design.
- 🎯 **Server-Side GA4 (Enterprise MP)** — Full GA4 Measurement Protocol payloads (`page_view`, `click`, `short_url_visit`) with `session_id`, `timestamp_micros`, and engagement time. Per-link Measurement ID validated before send; Settings **Test connection** verifies the real `G-XXXXXXXXXX` + API secret pair via the debug collector.
- 🔒 **Link Expiration & Caps** — Set `activated_at`, `expires_at`, `max_visits`, and single-use mode. `expiration_redirect_url` applies only when the link is **expired or inactive** (time/schedule); visit-cap and single-use limits return **410 Gone** (or a branded expiry page), not a fallback redirect.
- 🔑 **Password-Protected Links** — Visitors enter a password before being redirected. Passwords are hashed at rest (bcrypt/argon). Session-persisted unlock — visitors only see the prompt once per session.
- 🛑 **Redirect Warning Pages** — Show a confirmation screen before sending visitors off to external domains.
- 📉 **Log Aggregation & Pruning** — A nightly command aggregates raw visits into daily summaries and prunes records older than the configured retention window. Keeps the database lean even at millions of visits.
- 🏷️ **Retargeting Pixel Registry** — Define Meta Pixel, Google Tag, LinkedIn Insight, TikTok, and Pinterest pixels once, then attach them to any link via a simple checkbox list.
- 🔌 **REST API with Scoped Keys** — Full CRUD API, bulk create/update/delete, upsert by `external_id`, visit log + CSV export, tags/folders CRUD, public stats, SHA-256 hashed keys, `links:read-only` / `links:read-write` scopes, **`owner_user_id` required when `scope_links_to_user` is enabled**, and per-key rate limits.
- 📡 **Webhooks** — HMAC-SHA256 signed HTTP POST callbacks on `visited`, `created`, `expired`, and `limit_reached` events. Outbound URLs are validated against SSRF blocklists before dispatch.
- 📱 **Mobile Deep Linking** — Detect mobile visitors and launch the link directly inside 24+ native apps (Instagram, YouTube, Spotify, TikTok, WhatsApp, etc.).
- 🍎 **Universal Links & Android App Links** — Serve `apple-app-site-association` and `assetlinks.json` directly from your domain to enable OS-level native app integration.
- 💀 **Branded Expiry Pages** — When a link expires or hits its limit, visitors see a styled page with your site name instead of a bare 410 error.
- 👤 **User Attribution** — Each link records its creator. Avatar and name/email hover card visible in the table.
- 🕒 **Relative Time Badges** — Compact timestamps (`2h`, `5d`, `3mo`) with exact date on hover.
Self-hosted link management for Laravel/Filament — **not** a full Dub.co-style attribution SaaS (no workspaces, conversion tracking, or edge analytics pipeline).
> 💰 = paid plans only · ¹ Dub.co is open-source (AGPLv3) but self-hosting expects external managed services (Tinybird, PlanetScale, Upstash) · ² Throughput depends on your PHP/DB/Redis stack — not a CDN edge product · ³ VPN/proxy detection and Safe Browsing are **disabled by default**; enable in **Settings → Advanced**. Per-link webhooks use a URL only; subscribed events are configured globally in Settings → Developer.
---
## Architecture
Filament-native short-link engine inside your Laravel app — **not** a Dub.co-style attribution SaaS.
| Route | Middleware | Session | Purpose |
|-------|------------|---------|---------|
| `/s/{key}` | `throttle:120,1` only | No | Main redirect (stateless hot path) |
| `/s/public-stats/{key}` | `web` + throttle | Yes (password unlock) | Public HTML stats page + JSON API |
| Custom domain `/{key}` | same as `/s/{key}` | No | Branded links without prefix |
| `/api/short-url/*` | API auth + throttle | No | CRUD, bulk, exists, stats |
**Multi-tenant (single app):** `ShortUrlResource` scopes by `user_id` when `SHORT_URL_SCOPE_TO_USER=true` (default). API keys **must** include `owner_user_id` when link scoping is enabled; keys without an owner receive **403 Forbidden**. No workspaces, team RBAC, or billing.
**Stats retention:** raw visits pruned after ~90 days (configurable). Long-range API stats use daily rollups in `short_url_daily_stats`.
> **Required:** register the plugin's Filament assets immediately after install (and after every `composer update`). The admin panel relies on bundled **custom CSS and JavaScript** (QR designer, stats widgets, meta scraper, table actions). Without this step the plugin UI will not work correctly.
Then register the plugin in your Filament panel — see [Setup](#setup).
**Keep assets in sync:** add `@php artisan filament:assets` to `post-autoload-dump` in your host app's `composer.json` so updates re-publish CSS/JS automatically:
| Queue worker (when async) | Default `sync` needs no worker. For **`redis`** or **`database`**, run `php artisan queue:work {connection} --queue={name}` matching **Settings → General** (not necessarily `QUEUE_CONNECTION` in `.env`). Use **Test queue worker** in Settings to verify. |
| `SHORT_URL_SCOPE_TO_USER=true` | Filament users see only their own links (default) |
| Enable Safe Browsing / VPN in Settings | Security features are **off by default** |
| Global webhook signing secret | Required when global webhook is enabled |
Raw visit logs are pruned after the retention window (default 90 days); long-range API stats rely on daily rollups.
### Load & stress testing
Two complementary baselines — use both before high-traffic launches or after infra changes.
Four additive migrations ship with the v5.2.x schema wave. They do **not** drop, rename, or rewrite existing rows. Your links, visit history, API keys, and settings remain intact.
| `filter_qr_counts` | QR scan counts keyed by active dashboard filters |
All four migrations use no `->after()` column ordering hints and no database-specific `ENUM` types, so they run cleanly on **SQLite, MySQL, and PostgreSQL**.
**If you publish migrations to `database/migrations/`**, make sure both new files are present after `composer update`, then run `php artisan migrate`. If you never published migrations, the package service provider registers them automatically — `migrate` is still required once per release that adds schema changes.
**Rollback:** `php artisan migrate:rollback --step=4` removes the four v5.2.x additive migrations (`2026_06_08` through `2026_06_11`, one step per migration). Existing link data created before v5.2.0 is unaffected.
Optional publishes (config, translations, views). **`filament:assets` is not optional** — run it on every install and update (see [Installation](#installation)).
That `@source` directive is **only** for your own Filament theme build. It does not replace `php artisan filament:assets` — the plugin still needs its published CSS/JS bundle under `public/`.
| `authorizeSettingsUsing(Closure)` | `null` | Restricts access to the Settings page using a custom callback. See [Restricting Settings Access](#restricting-settings-access). |
---
## Restricting Settings Access
By default, any user who can view Short URLs can also access the Settings page. You can restrict this to specific roles or permissions using the `authorizeSettingsUsing()` method:
The callback can be any closure that returns a `bool`. When it returns `false`, the Settings page returns a 403 and the **Settings** button in the table header is automatically hidden.
Alternatively, define a `manageSettings` method in a Policy for the `ShortUrl` model — the plugin detects it automatically without any plugin configuration:
The package comes with a built-in admin settings dashboard. It is accessible directly from your sidebar menu under the same navigation group as your links.
Settings are stored dynamically in the database (`short_url_settings` table), cached for 3600 seconds by default, and immediately override config defaults. Legacy settings from `filament-short-url-settings.json` are automatically imported on first load.
***Route Prefix**: The slug prepended to short URLs (e.g. `s` for `/s/{key}`). Can be left empty to serve links directly from the root domain (e.g. `domain.com/{key}`).
***Queue Connection**: Laravel queue driver for async visit tracking. Default is **`sync`** (works without a background worker). Set to `database` or `redis` in **Settings → General** (or `SHORT_URL_QUEUE`) when you run a matching queue worker.
***Queue Name**: Target queue for visit tracking and counter sync jobs (default: `default`). Shown when connection ≠ `sync`.
***Queue mode callout**: One contextual info box per async driver with a **dynamic** worker command, e.g. `php artisan queue:work redis --queue=default`. Hidden for `sync`.
***Redis connection** (when Queue = `redis`): Host, port, password, database index, and optional key prefix — stored in settings and **override**`database.redis` / `queue.connections.redis` at runtime (no `.env` edit required for day-to-day ops).
***Test Redis connection**: PING + counter-style Redis probe using current form values (works before Save).
***Test queue worker**: Enqueues a probe job and confirms a worker processes it within 12 seconds; shows the exact worker command if not.
***Headers** (Edge Resolution, recommended behind CDN): Reads country/city from edge headers (`CF-IPCountry`, `CloudFront-Viewer-Country`, etc.). Saving settings with this driver **automatically enables****Trust CDN & Proxy Headers**. When headers are missing, the plugin falls back to MaxMind, then ip-api.com (rate-limited).
***IP-API** (Online): Direct lookup via `ip-api.com` with configurable timeout and built-in rate limiting (40 requests/minute guard).
> **Tip:** Enable **Trust CDN & Proxy Headers** only when your app sits behind a real CDN or reverse proxy. The Headers driver turns this on for you on save — do not enable it on a server that receives traffic directly from the internet.
***`click`** — outbound link metadata (`link_url`, `link_domain`)
***`short_url_visit`** — plugin-specific dimensions (`url_key`, device, country, QR flag)
***`session_id`**, **`timestamp_micros`**, **`engagement_time_msec`** — required for GA4 session reporting
Configuration:
***GA4 API Secret** — Create in Google Analytics under `Admin → Data Streams → Measurement Protocol API secrets`.
***Per-link Measurement ID** — Set `ga_tracking_id` (`G-XXXXXXXXXX`) on each link. Invalid IDs are rejected before HTTP.
***Firebase App ID** (optional) — Use instead of Measurement ID for Firebase streams.
***Test connection** — In **Settings → GA4**, enter your Measurement ID and click **Test connection**. Uses the GA4 **debug collector** with your real stream ID + secret (not a placeholder).
Privacy: `client_id` is a deterministic SHA-256 hash of IP + User Agent — no raw PII sent to Google.
***Buffer Click Counts**: Toggling this option buffers total and unique visit count increments in the application cache (or **dedicated Redis** when Queue Connection = `redis` — then buffering is **automatic** and the toggle is disabled).
***Cron Synchronization**: When enabled, the synchronization command flushes counts to the database. The package automatically registers this in the Laravel Scheduler to run every minute when counter buffering is active, so you only need to ensure the standard Laravel schedule runner (`* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1`) is running on your server.
***Enable Daily Aggregation**: When enabled, the stats are summarized. The package automatically registers the aggregation command in the Laravel Scheduler to run daily at 02:00, so you only need to ensure the standard Laravel schedule runner is running on your server.
***Prune Raw Logs After (days)**: Raw visit records older than this threshold are permanently deleted after aggregation. Set to `0` to disable pruning. Default: `90` days.
#### Rate Limiting / Bot Protection
Prevent redirect abuse and bot traffic flooding:
***Enable Rate Limiting**: Activates per-IP rate limiting on all redirect routes.
***Max Redirects Allowed**: Maximum number of redirect requests per IP within the decay window. Default: `60`.
***Decay Window (seconds)**: The rolling time window for the rate limiter. Default: `60` seconds.
When a client exceeds the limit, a `429 Too Many Requests` response is returned with a `Retry-After` header.
These options were previously `.env`-only; they are now editable under **Settings → Performance & Security** (`?tab=advanced`) → **Analytics & Bot Detection**:
***Click deduplication** — Ignore repeat clicks from the same IP within a configurable window (default: off, 1 hour). Mirrors `SHORT_URL_CLICK_DEDUP` / `SHORT_URL_CLICK_DEDUP_HOURS`.
***Verify Googlebot IP** — Optional reverse-DNS + forward IP check for Googlebot user agents. Mirrors `SHORT_URL_VERIFY_GOOGLEBOT_IP`.
***Bot debug secret** — Allows `?bot=1` preview testing in production when the query matches `SHORT_URL_BOT_DEBUG_SECRET`.
- Passwords are **hashed at rest** using Laravel's `Hash` facade (bcrypt/argon). Legacy plain-text passwords created before v5.2.0 are still verified and automatically re-hashed on the next save.
> **Stateful vs stateless routes**: The main `/s/{key}` redirect uses only rate limiting by default (no `web` middleware). Password unlock uses `/s-auth/{key}`, which loads sessions. Visit tracking defaults to the `sync` queue so redirects work without a queue worker; switch to `redis`/`database` in **Settings → General** for async tracking on high-traffic sites.
When enabled in **Settings → Advanced**, filter anonymous proxy, VPN, or Tor connections to keep analytics clean and prevent abuse. Disabled by default.
When enabled in **Settings → Advanced**, scan and verify user-provided target URLs against Google's Safe Browsing API on creation, edit, **and redirect** (cached per destination URL). Disabled by default.
When a short URL is expired, deactivated, or has reached its maximum visit limit, it needs to handle the redirect gracefully:
* **Custom Fallback URL**: If configured, visitors are immediately redirected to the `expiration_redirect_url` target.
* **Branded Expiry Page (Default)**: If no fallback URL is specified, the system displays a premium branded, fully localized, dark-mode compatible HTML page (`expired.blade.php`) instead of a generic browser `410 Gone` error.
- Automatically displays your customized **Site Name** from **Settings → General** (falls back to `config('app.name')`) and the host application logo when the `setting('logo_path')` helper is defined. The same branding is used on password prompts, public stats pages, and other visitor-facing screens.
If your database contains legacy single-strategy rules (e.g. `'type' => 'device'` or `'type' => 'geo'`), the plugin handles them automatically:
* **Redirection Engine**: The redirect system detects the legacy structure and processes it on-the-fly using the legacy strategy.
* **Filament UI**: When loading a link with legacy rules, the Filament Form automatically upgrades and hydrates them to equivalent new multi-filter rules.
A single short URL can distribute traffic across 2–5 landing pages using configurable weights. This is useful for comparing conversion rates between different pages without changing the link you've already shared.
### Two ways to set it up
**Root-level split test** — Set the **Destination Type** to `A/B Split Test` directly on the short URL. All visitors hitting that link will be distributed across your variants.
**Nested inside a targeting rule** — Combine split testing with audience targeting. For example: send mobile visitors from Poland into a 50/50 test, while everyone else goes to a single fallback URL.
### Weights
Each variant gets a percentage weight. Weights must sum to exactly **100%** and can be adjusted in 1% increments. The admin form shows an interactive split bar — drag the handles or hit **"Balance weights"** to divide traffic evenly. When you add or remove a variant, weights are rebalanced automatically.
UTM parameters and other query strings on the original click are forwarded to whichever variant is selected.
### Analytics
Every visit records which variant was resolved (`selected_variant` column in the visit log). The link's statistics dashboard shows a **Variant Clicks Distribution** bar chart so you can compare actual click shares against your configured weights.
### REST API
For root-level split tests, pass `rotation_variants` in the request body:
This package supports two distinct levels of mobile app integration: **Per-Link App Linking** (client-side redirects using custom schemes) and **Global Deep Linking Files** (domain association files for OS-level native integration).
When creating or editing a short URL, the **Targeting** tab includes an **App Linking** section where you configure automatic redirects into native mobile applications.
* **How it works**: If a destination URL matches one of the 24+ pre-configured native applications (such as YouTube, TikTok, Instagram, Facebook, Spotify, WhatsApp, Messenger, etc.), the plugin can bypass standard web views for mobile visitors.
* **The Interstitial Experience**: If **Auto open app on mobile** is enabled, mobile visitors are shown a premium glassmorphic redirect interstitial page that triggers the corresponding custom URL scheme (e.g. `whatsapp://`, `instagram://`, `youtube://`) to launch the native app directly, with fallback options to open in a web browser.
* **Interactive Panel Preview**: Inside the Filament resource edit form, a live preview widget demonstrates if the URL was matched, showing:
- The matched app with its official favicon.
- The calculated deep link scheme.
- An interactive grid showing all supported native applications.
Supported apps include: YouTube, TikTok, Instagram, X (Twitter), Spotify, Facebook, Reddit, Snapchat, WhatsApp, LinkedIn, Pinterest, Twitch, Netflix, Google Docs/Sheets/Slides/Maps, Messenger, Apple Music, Airbnb, TripAdvisor, Amazon, StockX, Booking, AliExpress.
---
### 2. Global Deep Linking Files (Universal Links & App Links)
To support seamless OS-level integrations without browser intermediaries—such as iOS Universal Links and Android App Links—you can serve domain association files directly from your application's root domain.
> [!IMPORTANT]
> **Disabled by default**: This feature is turned **off** by default. You can enable it and customize the association JSON in your settings panel.
#### Enabling and Configuration
1. Open the **Settings** panel from your Filament sidebar.
2. Navigate to the **Deep Linking** tab.
3. Toggle **Enable Deep Linking Files** to ON.
4. Fill in your configurations:
* **apple-app-site-association (iOS)**: The JSON configuration representing your iOS application IDs and supported paths (e.g., `/s/*`). This will be served at `/.well-known/apple-app-site-association` and `/apple-app-site-association` with the `application/json` content-type header.
* **assetlinks.json (Android)**: The Digital Asset Links JSON array representing your Android application package names and SHA-256 certificate fingerprints. This will be served at `/.well-known/assetlinks.json`.
The `getCachedStats()` model method **automatically merges** data from both tables: historical days come from `short_url_daily_stats`, while today's data is merged live — from **Redis today buffer** when Settings queue is `redis`, otherwise from a short-TTL SQL micro-cache on `short_url_visits`.
**Filtered stats** (dashboard filters active) use `FilteredStatsCollector`: pruned history is read from **cross-dimensional daily rollups** (`cross_dimensional_stats`, `cross_filter_pairs`) where possible, the retention window contributes exact `unique` via one SQL query, and only **today** (or 3+ simultaneous filters) requires a bounded raw scan. Results are cached via `StatsCacheHelper` (works on file, database, Redis, Memcached, and array cache drivers). Historical stats cache is **not** invalidated on every visit when using the Redis scaling profile.
**Security breakdown** (bots / VPN widget) uses `getSecurityBreakdownStats()` with the same daily rollups plus a single aggregated query on raw visits.
#### 1. With a Queue Worker (Recommended for high traffic)
If your application runs a queue worker (e.g. via Supervisor), configure **Queue Connection** in **Settings → General** (or `SHORT_URL_QUEUE`) to `database` or `redis`. The package default is **`sync`** so redirects work out of the box without a worker.
- **Performance**: With `database` or `redis` queue, visitor clicks, Geo-IP lookups, GA4 hits, and webhook dispatches run in `TrackShortUrlVisitJob` / `SendWebhookJob`**after** the redirect response is sent — keeping the hot path lean. Latency still depends on your stack (DB, Redis, enabled security features, HTML interstitials).
- **Worker command**: Must match **Settings → General**, not necessarily `.env``QUEUE_CONNECTION`:
If you do not run background queue workers, set the **Queue Connection** to `sync`.
- **Automatic Fallback**: The plugin dynamically executes all jobs synchronously on the request thread. The visitor is redirected as soon as the synchronous processes (log recording, webhook calls, etc.) complete.
- **Fault Tolerance (Safety Nets)**: Webhook failures (timeouts, 500 errors) or database tracking glitches can happen. The plugin wraps all synchronous execution points in strict error boundaries (`try/catch`). **A failed tracking job or a slow/offline webhook will NEVER crash the visitor's redirect or programmatic REST API responses.** They are gracefully logged in the background.
When counter buffering is enabled and Settings queue is **not**`redis`, `IncrementVisitJob` can dispatch to the configured queue connection so counts survive cache evictions. With **queue = `redis`**, counters use dedicated Redis keys via `VisitCounterBuffer` and flush through `short-url:sync-counters`.
Instead of manually copy-pasting tracking pixel IDs (Meta, Google Tag, LinkedIn, TikTok, Pinterest) every time you create a new link, the package features a centralized **Retargeting Pixel Registry** with a Many-to-Many relationship. You define your marketing pixels once in the new **Pixel Registry** resource, and then easily select them via checkbox/list options when creating or editing short links.
- **Premium Design**: Built using the exact same modern glassmorphic look as the password protection and warning interstitial pages. Supports dark mode automatically.
- **Brand Customization**: Automatically renders the application logo (if defined via the `setting('logo_path')` helper) and the **Site Name Override** (configured globally in Settings, falling back to `config('app.name')`) to ensure the transition page looks professional and trust-instilling.
- **Micro-Animations & Smooth Redirect**: Displays a sleek animated loading spinner and a progress bar that smoothly fills from 0% to 100% in ~220-250ms. This short delay ensures browser execution time for the tracking scripts before performing a seamless `window.location.replace()` to the destination URL.
This unlocks remarketing to people who clicked your links **even when redirecting to external domains** (e.g. booking.com, amazon.com) where you cannot install your own tracking code.
> **Note:** These pixels fire **client-side** in the visitor's browser — completely separate from the server-side GA4 Measurement Protocol integration. Both systems work in parallel and do not interfere with each other.
> **Privacy/GDPR Note:** You are responsible for ensuring that firing these pixels complies with applicable privacy regulations and your cookie consent mechanism.
Branded links (e.g. `go.company.com/abc123` instead of `yourdomain.com/s/abc123`) significantly increase click-through rates and build trust. This package includes a full-featured custom domain manager out-of-the-box.
1.**Multi-record DNS verification**: Resolves all A records and CNAME targets via `dns_get_record()` (with `gethostbynamel` / `dig` fallbacks). Matches when **any** resolved IP intersects the app host, or when a CNAME chain points to `app.url`.
2.**Activation gate**: Domains cannot stay active without passing DNS verification (`custom_domains.enforce_dns_on_activate`, default `true`). Failed activation attempts are automatically deactivated.
3.**DNS Setup Instructions**: Step-by-step instructions for non-technical users to connect their domain in their registrar (GoDaddy, Cloudflare, Namecheap, etc.) directly in the Filament UI.
4.**Verified-Only Assignment**: Only domains that are **active and verified** can be associated with short URLs (enforced in Filament and API validation).
5.**Root-Level Redirections**: When a visitor hits a custom domain link (e.g. `go.company.com/abc123`), the request is matched directly at the root path — omitting the default `/s/` prefix automatically.
6.**Scheduled re-verification**: `short-url:verify-custom-domains` re-checks DNS daily for active domains.
1. Go to the **Custom Domains** page in the Filament admin panel (sidebar → Links → Custom Domains).
2. Click **Create** and enter your domain name (e.g. `go.company.com`).
3. Follow the DNS Setup instructions shown in the modal to point your DNS records to the application server.
4. Click **Setup DNS / Verify** inside the table actions. Once DNS resolves correctly, the domain status changes to **Active**.
5. In the Short URL creation form, select your custom domain from the **Custom Domain** dropdown. The generated short URL will use your branded domain automatically.
### DNS Setup (for users)
You need to add **one** of the following records at your DNS provider:
Or, if the custom domain points directly to the same server running Laravel:
```nginx
server {
listen 80;
listen 443 ssl;
server_name go.company.com;
root /var/www/your-app/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
```
#### Caddy
```caddyfile
go.company.com {
root * /var/www/your-app/public
php_fastcgi unix//run/php/php8.3-fpm.sock
file_server
}
```
#### Apache
```apache
<VirtualHost *:80>
ServerName go.company.com
DocumentRoot /var/www/your-app/public
<Directory /var/www/your-app/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
> **Note:** The Laravel application automatically detects the incoming `Host` header and routes the request to the correct short URL via the fallback route. No additional application configuration is needed beyond registering the domain in the Filament UI.
### Server Requirements
- DNS verification runs natively using PHP's built-in `dns_get_record()` function.
- If native DNS functions are disabled or fail, the plugin falls back to the `dig` CLI tool (`apt install dnsutils` on Debian/Ubuntu, `yum install bind-utils` on CentOS/RHEL).
- Verification compares resolved records against `config('app.url')` host IPs — no hardcoded server IP or `SERVER_ADDR` fallback.
- The DNS verification runs synchronously in the admin panel when the **Verify** button is clicked. For production use with many domains, the scheduled `short-url:verify-custom-domains` command handles bulk re-checks.
**Managing API Keys:** Go to **Settings → API & Webhooks → Developer API Keys** and add named keys. Each key can be individually activated or deactivated without deleting it.
For security, new API keys are hashed using SHA-256 and stored securely in the database. The plain key is displayed only once during generation via a persistent warning notification in the Filament UI. All keys are authenticated using constant-time string comparisons (`hash_equals()`) to prevent timing attacks.
Each API key can be assigned a **scope** that restricts its access level:
| Scope | Allowed Methods | Use Case |
|---|---|---|
| `links:read-write` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` | Full control (default) |
| `links:read-only` | `GET` only | Integrations that only need to read link data (e.g., dashboards, reporting tools) |
A `links:read-only` key attempting a write operation (`POST`, `PUT`, `PATCH`, `DELETE`) will receive a `403 Forbidden` response.
### Per-Key Rate Limiting
Each API key can have its own **individual rate limit** (requests per minute), independent of the global route throttle. This allows high-trust integrations to use a higher limit while restricting untrusted keys more aggressively.
Configure the rate limit for each key in **Settings → API & Webhooks → Developer API Keys**. The default is **60 requests per minute**.
When **`scope_links_to_user`** is enabled (default), every API key **must** include an **`owner_user_id`**. All link queries and mutations through that key are scoped to short URLs where `user_id` matches — useful for multi-user panels or per-team integrations. Keys without an owner ID are rejected with **403 Forbidden**.
When link scoping is **disabled** (`SHORT_URL_SCOPE_TO_USER=false`), `owner_user_id` is optional and keys without an owner retain access to all links (subject to scope and rate limits).
Configure in **Settings → API & Webhooks → Developer API Keys → Owner User ID**.
All endpoints are prefix-grouped under `/api/short-url/` and are protected by the API Key middleware. The effective rate limit is the lower of the global route throttle and the per-key rate limit.
| `folder_id` | integer | ❌ | Assign link to a folder (`short_url_folders.id`) |
| `tag_ids` | array of integers | ❌ | Up to 5 tag IDs to attach |
| `is_archived` | boolean | ❌ | Archive instead of delete (hidden from default list) |
| `utm_source` / `utm_medium` / `utm_campaign` / `utm_term` / `utm_content` | string | ❌ | Stored on the link and merged into the destination URL on redirect (existing query params on the destination are preserved; link-level values fill gaps only) |
| `ref` | string | ❌ | Appended as `?ref=` on redirect when not already present |
| `public_stats_enabled` | boolean | ❌ | Enable the public stats page + JSON endpoint for this link (default: `false`) |
| `public_stats_password` | string | ❌ | Optional password for public stats (hashed at rest; session unlock on HTML, Bearer/POST for JSON) |
**Response:** `200 OK` with paginated visit records (`id`, `visited_at`, `browser`, `country_code`, `referer_host`, `is_qr_scan`, UTM fields, etc.). The raw password hash is never included.
#### `POST /api/short-url/links/bulk-delete`
Delete multiple short URLs in a single request (max 100).
```bash
curl -X POST https://yourdomain.com/api/short-url/links/bulk-delete \
-H "X-Api-Key: sh_key_your_key_here" \
-H "Content-Type: application/json" \
-d '{"ids": [1, 2, 3]}'
```
Alternatively, pass `"keys": ["promo26", "summer24"]` instead of `"ids"`.
**Response:** `200 OK`
```json
{
"message": "Short URLs deleted successfully.",
"deleted": 3
}
```
#### `PATCH /api/short-url/links/bulk-update`
Update multiple short URLs at once (max 100). Supported fields in the `data` object: `is_enabled`, `notes`, `expires_at`, `max_visits`.
Create or update a link by **`external_id`**, or by matching **`url_key` + `destination_url`**. Accepts the same fields as `POST /links`. When a match exists, unique validation ignores the existing record.
```bash
curl -X PUT https://yourdomain.com/api/short-url/links/upsert \
-H "X-Api-Key: sh_key_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"external_id": "crm-deal-42",
"destination_url": "https://example.com/offer",
"url_key": "offer42",
"notes": "Synced from CRM"
}'
```
**Response:** `200 OK` (updated) or `201 Created` (new link).
#### `POST /api/short-url/links/bulk`
Bulk-create up to **100** links in one request.
```bash
curl -X POST https://yourdomain.com/api/short-url/links/bulk \
Webhooks allow external systems to receive real-time HTTP POST notifications when events occur on your short URLs. Payloads are dispatched **asynchronously** via the Laravel Queue — redirects are never blocked.
***Where to configure**: Set the `Dedicated Webhook URL` in the **Marketing & API** tab of a specific short URL (or pass it via the `webhook_url` parameter in the REST API).
***How it works**: When a visitor clicks the short URL, or when the link is programmatically created via the REST API, a webhook notification is sent directly to this URL.
***Bypassing Global Filters**: Dedicated webhooks **always fire** for these events and are completely independent of the global *Monitored Webhook Events* settings. This is useful for third-party landing pages or integrations that need to track a specific link's traffic without routing all package events.
***Where to configure**: Set a **Global Webhook URL** in **Settings → API & Webhooks → Global Webhook Configuration**.
***How it works**: Fires for all links that *do not* have their own Dedicated Webhook URL configured. It will only fire for the specific event types selected in the **Monitored Webhook Events** setting.
Outgoing webhooks can be cryptographically signed using an HMAC-SHA256 signature to verify that the request originated from your system:
1. Configure a **Webhook Signing Secret** in your Settings panel under **API & Webhooks**.
2. When configured, outgoing HTTP POST payloads will include the signature in the `X-ShortUrl-Signature` header, which is calculated as `hash_hmac('sha256', $payloadJson, $secret)`.
3. The receiver can verify the signature by re-calculating the HMAC of the raw request payload using the shared secret and comparing it using `hash_equals()`.
All webhook requests are HTTP POST with `Content-Type: application/json` and the following payload structure:
```json
{
"event": "visited",
"timestamp": "2026-06-02T10:00:00+00:00",
"short_url": {
"id": 1,
"destination_url": "https://example.com",
"url_key": "abc123",
"short_url": "https://yourdomain.com/s/abc123",
"total_visits": 48,
"unique_visits": 32
},
"visit": {
"id": 101,
"visited_at": "2026-06-02T10:00:00+00:00",
"device_type": "desktop",
"browser": "Chrome",
"browser_version": "124.0",
"operating_system": "Windows",
"operating_system_version": "10",
"country": "Poland",
"country_code": "PL",
"city": "Warsaw",
"referer_url": "https://linkedin.com",
"referer_host": "linkedin.com",
"utm_source": "linkedin",
"utm_medium": "social",
"utm_campaign": "spring_sale",
"utm_term": null,
"utm_content": null
}
}
```
### Retry Policy
If the webhook endpoint returns a non-2xx response or is unreachable, the `SendWebhookJob` will automatically retry up to **3 times** with a **10-second backoff** between attempts. Failed jobs land in your queue's failed jobs table after exhausting retries.
Before a webhook job is queued or executed, the target URL is validated against an SSRF blocklist. Requests to `localhost`, link-local/private IP ranges, cloud metadata endpoints, and `.local`/`.internal` hostnames are rejected. Hostnames are **DNS-resolved** — URLs that resolve to private IPs are blocked even when the hostname looks public. This applies to per-link webhooks, global webhooks, OG image imports, and the iframeable pre-check redirect chain.
The global webhook URL fires only when **Enable Global Webhook** is turned on in Settings (or `global_webhook_enabled` is `true` in config). Resolution order:
2. If no per-link URL is set, **global webhook is enabled**, a **Global Webhook URL** is configured, and the event type is in the selected **Monitored Events** list → use the global URL.
The `user` configuration block connects the package to your application's `User` model so that creator avatars and details are displayed in the admin table:
| `SHORT_URL_LIVE_FEED_SSE_MAX_DURATION` | `live_feed.sse_max_duration_seconds` | `120` | Max SSE stream duration before client reconnects. |
| `SHORT_URL_LIVE_FEED_REDIS_PUSH` | `live_feed.use_redis_push` | `true` | Use Redis pub/sub push when cache is Redis + PhpRedis; `false` forces SSE poll. |
| `SHORT_URL_TRUST_CDN_HEADERS` | `trust_cdn_headers` | `false`¹ | Trust proxy/CDN headers to extract real client IP and country. |
| `SHORT_URL_CUSTOM_DOMAIN_ENFORCE_DNS` | `custom_domains.enforce_dns_on_activate` | `true` | Require passing DNS verification before a custom domain can stay active. |
| `SHORT_URL_VERIFY_GOOGLEBOT_IP` | `bot_detection.verify_google_bot_ip` | `false` | Verify Googlebot user agents via reverse DNS + forward IP check. |
| `SHORT_URL_BOT_DEBUG_SECRET` | `bot_detection.debug_secret` | `null` | Secret value for `?bot=` debug param outside local/testing environments. |
> ¹ When `geo_ip.driver` is `headers`, **Trust CDN & Proxy Headers** is automatically enabled on settings save and at runtime. The effective default for CDN deployments is therefore `true`.
> **Database Configuration Preferred**: Avoid configuring large, multi-line JSON blocks (such as `apple-app-site-association` and `assetlinks.json`) via `.env` file environment variables as it is error-prone and can cause parsing issues. The recommended approach is to configure them dynamically via the **Filament Settings Panel**, which stores them securely in the database with real-time JSON validation.
| `short-url:sync-counters` | Flushes buffered visit counts from cache to the database. Schedule every minute when counter buffering is enabled. |
| `short-url:aggregate-and-prune` | Aggregates previous days' raw visits into `short_url_daily_stats` and prunes raw records older than the configured retention period. Schedule daily (e.g. at `02:00`). |
As of version `1.3.0`, **you no longer need to manually copy scheduled commands into your host application code!** The plugin automatically registers the required tasks inside its ServiceProvider booted phase, dynamically respecting your Settings GUI toggles:
- **`short-url:aggregate-and-prune`** is scheduled **daily at 02:00** (runs automatically only if **Enable Daily Aggregation** is ON).
- **`short-url:sync-counters`** is scheduled **every minute** (runs automatically only if **Buffer Visit Counts in Cache** is ON).
> For the scheduler to fire these tasks automatically, your server must run the standard Laravel Scheduler cron job. Add this single cron entry to your server:
> *Note: These commands execute synchronously within the scheduling process. You do **not** need a queue worker (`php artisan queue:work`) to run them.*
If you prefer to configure the schedule manually, you can still define them in `routes/console.php` (ensure the corresponding toggles are turned OFF in your Settings panel to avoid redundant executions):
You can programmatically generate, customize, and trace short URLs anywhere in your code using the static `destination` builder on the `ShortUrl` model:
*`getCachedStats(): array` — Returns cached key metrics for dashboard charts. Automatically merges daily aggregated stats with today's raw visits.
*`resolveDestinationUrl(Request $request): string` — Evaluates active targeting rules (device, geo, rotation) and returns the correct destination URL for the current visitor.
The package fires the `ShortUrlVisited` event inside the queue job. You can listen to it to trigger custom logic, Slack notifications, or syncing external CRMs:
Visits from scrapers, search bots, and web crawlers are **not recorded** in the visit log at all — they never increment counters or appear in analytics. The redirect engine still serves appropriate responses (OG preview HTML for bots when custom meta tags are configured, or a standard redirect otherwise).
Bot detection matches user agents and referers against a configurable list (Googlebot, Bingbot, Applebot, Facebook, Twitter, Slack, WhatsApp, and generic curl/http clients). Optional Googlebot reverse-DNS IP verification is available via `SHORT_URL_VERIFY_GOOGLEBOT_IP`.
The `?bot=1` query parameter (for testing link previews) is restricted to `local`/`testing` environments or requires a matching `SHORT_URL_BOT_DEBUG_SECRET` (configurable in **Settings → Advanced**).
---
## Live Activity Feed (SSE)
The **Live Feed** tab (`/{record}/stats/live`) streams visit updates to the Filament panel over **Server-Sent Events (SSE)**. The browser opens an authenticated `EventSource` to `GET /short-url/live-feed/{id}/stream` and calls Livewire `onStreamUpdate()` only when a new visit id arrives — no full-page or `wire:poll` refresh.
### How the cursor works (all environments)
On every recorded visit, `TrackShortUrlVisitJob` calls `LiveFeedBroadcaster::publish()` which stores the latest visit id (TTL 24 h) and, when Redis is available, **`PUBLISH`**es it on a per-link channel.
| No Redis (sync/database queue + database cache) | — | **SSE poll** on cache; DB fallback when cursor cold | Only on cold cursor |
**Typical production setup:** set **Queue Connection = `redis`** in **Settings → General** (no need to change `CACHE_STORE` if it stays `database`). Ensure `REDIS_CLIENT=phpredis` and a running `queue:work` for async visit jobs.
**Predis note:** cursor + `PUBLISH` still work, but SSE uses polling because Predis cannot reliably time out `SUBSCRIBE` for heartbeats.
**Queue timing:** with **`sync`** queue, the cursor updates on the redirect thread. With **`redis`** queue, it updates when the worker runs `TrackShortUrlVisitJob` (typically sub-second).
| `SHORT_URL_LIVE_FEED_SSE_MAX_DURATION` | `120` | Max SSE connection length before client auto-reconnects |
| `SHORT_URL_LIVE_FEED_REDIS_PUSH` | `true` | Set `false` to force SSE poll even with PhpRedis + Redis |
Filament badge and Alpine client **reconnect automatically** after the stream closes (every ~120 s), passing the updated `cursor` so no visits are skipped.
---
## Public Stats (Shareable Analytics)
Each link can expose a **read-only public stats page** and matching **JSON API** without API key authentication — useful for sharing campaign dashboards or embedding click counts on landing pages.
> **Upgrade note:** requires migration `2026_06_08_000001` (`public_stats_enabled`, `public_stats_password`). Run `php artisan migrate` after updating the package.
### Enable in Filament
Open the link row **⋮ menu → Public stats** (`P`):
| State | What you see |
|---|---|
| **Off** | Toggle + hint; save to generate the shareable URL |
| **On** | Status badge, copyable link, **Open** button, optional password field |
| **Password** | Leave blank to keep the current hash; disable public stats to clear it |
The public URL is built from the link’s `url_key` (and custom domain when configured), e.g. `https://yourdomain.com/s/public-stats/promo26`.
### Public HTML page (browser)
A normal `GET` in the browser renders a branded stats dashboard:
- Summary cards: total / unique visits, today, this week, this month, QR scans
- Visits-by-day table with optional **date_from** / **date_to** filters
- **Site name** from **Settings → General → Site Name Override** (same as password/expiry pages); logo from host `setting('logo_path')` when available
- Optional password form (session unlock, same pattern as `/s-auth/{key}`)
Legacy alias: `GET /short-url/public-stats/{url_key}` behaves the same.
### JSON API (embeds & curl)
Request JSON explicitly when integrating programmatically:
If a password is set, send it in the **POST body** or **`Authorization: Bearer …`** header (not in the query string):
```bash
curl -X POST -H "Content-Type: application/json" \
-d '{"password":"secret"}' \
"https://yourdomain.com/s/public-stats/promo26"
curl -H "Authorization: Bearer secret" \
-H "Accept: application/json" \
"https://yourdomain.com/s/public-stats/promo26"
```
You can also enable or configure public stats via the REST API (`public_stats_enabled`, `public_stats_password` on create/update).
**Response:** `200 OK` with a **public-safe stats subset** (`totalVisits`, `uniqueVisits`, `visitsToday`, `visitsThisWeek`, `visitsThisMonth`, `qrScans`, `visitsByDay` — no raw visit rows or sensitive metadata). Returns **`404`** for unknown keys **and** disabled public stats (prevents slug enumeration). Returns **`403`** when password is missing, wrong, or supplied in the query string. Rate limited to **10 requests/minute per IP per link**.
---
## SEO, Open Graph & Link Cloaking
Control how each short link appears on social networks, in search engines, and in the browser address bar — all from the **SEO & Social** tab:
| Toggle / field | Config key | What it does |
|---|---|---|
| **Search Engine Indexing** | `do_index` | When **off** (default), every redirect and interstitial sends `X-Robots-Tag: noindex, nofollow` and `<meta name="robots" content="noindex, nofollow">`. When **on**, a canonical URL is emitted and bots may index the short link. |
| **Link Cloaking** | `is_cloaked` | When **on**, human visitors see the destination embedded in an iframe on your short-link domain — the address bar keeps your branded URL. Social crawlers receive a standalone OG HTML page instead of the iframe. |
| **OG Title** | `og_title` | Custom Open Graph / Twitter Card title (falls back to link title or URL key). |
| **OG Image** | `og_image` | Upload or auto-import a preview image (WebP, stored on disk). Rendered with `og:image:width` / `og:image:height` for large-card previews. |
### How it works on redirect
1.**Social bots & crawlers** (Facebook, LinkedIn, X, Slack, WhatsApp, Googlebot, …) hit your short URL and receive a dedicated HTML page with full OG/Twitter meta tags — **without**`meta refresh` or JavaScript redirects, so preview scrapers can read the tags reliably.
2.**Human visitors** on a cloaked link see the destination inside an iframe; on a normal link they are redirected instantly.
3.**Search indexing off** applies `noindex` headers on every response type: direct redirects, password prompts, warning pages, expiry pages, pixel loaders, and OG preview pages.
### Auto-scrape & live preview
- Click **Scrape from destination** in the Filament form to pull `og:title`, `og:description`, and `og:image` from the target URL (SSRF-safe, streaming fetch).
- Imported OG images are processed to WebP and promoted to permanent storage on save.
- A live **Social Preview** sidebar shows exactly how the link card will look before you publish.
### Iframeable pre-check (cloaking)
Before enabling **Link Cloaking**, verify the destination allows iframe embedding:
```bash
curl -X POST https://yourdomain.com/short-url/check-iframeable \
Sites that send `X-Frame-Options: DENY/SAMEORIGIN` or restrictive `Content-Security-Policy: frame-ancestors` headers will return `"iframeable": false`.
OG metadata can also be fetched manually via the authenticated scrape endpoint: `GET /short-url/scrape-meta?url=...`
- **Upgrade-safe schema changes** — New columns are nullable or have safe defaults; indexes are additive. See [Upgrading](#upgrading) for the v5.2.0 migration details.
Recent releases are summarized below. For the full version history (v1.2.0–v4.0.0), see **[CHANGELOG.md](CHANGELOG.md)**.
### v5.2.3
> **Production Redis mode, stats scaling, and Settings diagnostics.**
#### Settings → Redis & queue
- **Redis in Settings GUI** — Host, port, password, DB index, key prefix when Queue Connection = `redis`; overrides `database.redis` and `queue.connections.redis` at runtime.
- **Test Redis connection** — Health check with PING + INCR/SADD probe (preview unsaved form values).
- **Test queue worker** — Probe job + 12 s wait; reports exact `php artisan queue:work {connection} --queue={name}` if no worker.
- **Unified queue callout** — One box per async driver with dynamic worker command; hidden for `sync`.
#### Stats scaling (high traffic)
- **`StatsScalingProfile`**, **`PluginRedisConnection`**, **`VisitCounterBuffer`**, **`TodayStatsBuffer`**, **`StatsVisitRecorder`** — Dedicated Redis counters/stats when Settings queue is `redis`; historical stats cache no longer busted on every visit.
- **Auto counter buffering** — Forced on when queue = `redis`.
- **Live feed** — Shares queue Redis infra with counters/stats when configured.
- **Public HTML dashboard** — Browser `GET /s/public-stats/{key}` with branded layout (Settings site name + optional host logo).
- **Dual format** — Same route serves HTML by default; JSON via `Accept: application/json`, `?format=json`, or authenticated POST.
- **Password flows** — HTML uses session unlock after form POST; JSON uses POST body or `Authorization: Bearer`. Query-string passwords rejected (`403`).
> **Enterprise hardening, airtight counters, and maintainability.**
#### Security (P0 / P1)
- **Stored XSS fixes** — Pixel interstitial and Alpine stats widgets use `@json()` instead of `addslashes()` for JavaScript context output.
- **Atomic `max_visits`** — `VisitSlotReservation` reserves slots at redirect time with fast-path `UPDATE … WHERE total_visits < max_visits` and pessimistic locking near the cap; parallel bursts cannot exceed the limit.
- **Single-use + bots** — Social preview bots no longer consume single-use links.
- **Counter buffer integrity** — Cache increments are reverted before DB fallback so `SyncBufferedCountersCommand` cannot double-count.
- **API tenancy** — Keys without `owner_user_id` are rejected when `scope_links_to_user` is enabled; folder/tag/pixel ownership validated on API writes (IDOR fix).
- **Redirect order** — `single_use` and `max_visits` run before `auto_open_app_mobile` interstitial and visit tracking.
- **`max_visits` without tracking** — Visit caps enforced even when `track_visits=false` (counters still increment).
#### Performance (P1)
- **`max_visits_pessimistic_remaining`** — Configurable threshold (default 5) for when pessimistic locking kicks in.
- **Live feed adaptive SSE** — Redis cache + PhpRedis → pub/sub push (instant); Predis or non-Redis cache → SSE poll on cache cursor; auto-reconnect with cursor; see [Live Activity Feed](#live-activity-feed-sse).
- **Stats cache lock** — `Cache::lock` on cold misses prevents thundering herd across widgets.
- **List table batching** — `preloadBufferedCountersForIds()` loads buffered counters in one round-trip.
#### Quality (P2)
- **Public stats** — Uniform `404` for unknown/disabled keys; response limited to a public-safe subset.
- **Octane-safe A/B variant** — `resolved_ab_variant` stored on `request()->attributes`, not `app()->instance()`.
- **Filtered uniques** — Daily rollup `unique_visits_count` merged in `FilteredStatsCollector`.
- **`findByKey()`** — Queries by `domain_scope_id` for better index use.
#### Code quality
- **`HasStats` split** — Facade trait composing `HasVisitCounters`, `HasStatsCache`, `HasStatsQueries`, and `HasSecurityStats`; atomic reservation centralized in `VisitSlotReservation`.
- **i18n audit** — Filament panel, visitor interstitials, and API/redirect error messages use `__('filament-short-url::default.*')` translation keys (EN + PL).
### v5.2.1
> **Policy, API stats, SSE live feed, and load baselines.**
#### Authorization & API
- **`ShortUrlPolicy`** — Per-link authorization for view, update, delete, and settings access. Aligns with `scope_links_to_user` and `user_id`. Override in your app by registering your own policy.
- **API stats filters** — `GET /api/short-url/links/{idOrKey}/stats` supports the same cross-filters as the Filament dashboard (country, device, date range, UTM, and more). Query aliases: `country`, `device`. Response includes `meta.filters`.
#### Live feed
- **SSE stream** — Authenticated endpoint pushes visit updates to the admin panel without Livewire polling.
- **Configurable timing** — `live_feed.sse_interval_seconds` and `live_feed.sse_max_duration_seconds`.
- **Adaptive transport (v5.2.2+)** — Auto-detects Redis cache + PhpRedis for pub/sub push; falls back to cache-cursor polling (Predis, file, database drivers). See [Live Activity Feed](#live-activity-feed-sse).
- **`scripts/k6/redirect-baseline.js`** — Concurrent HTTP load test for staging or production-like environments (requires [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/)).
### v5.2.0
> **Main release theme: SEO & Social** — First-class per-link Open Graph metadata, Search Engine Indexing control, and Link Cloaking. None of these existed in v5.1.x or earlier.
#### SEO, Open Graph & Link Cloaking *(headline)*
- **New SEO & Social tab** — Dedicated Filament form tab for `og_title`, `og_description`, `og_image`, **Search Engine Indexing** (`do_index`), and **Link Cloaking** (`is_cloaked`). Live social preview sidebar in the admin panel.
- **Custom Open Graph meta** — Per-link OG/Twitter tags (`og:site_name`, image dimensions, canonical URL, HTTPS-normalized images) served to Facebook, LinkedIn, X, Slack, WhatsApp, and other preview bots.
- **Search Engine Indexing (`do_index`)** — Per-link toggle (default: off). When disabled, all response types emit `X-Robots-Tag: noindex, nofollow` and matching `<meta name="robots">` tags — redirects, password prompts, warning pages, expiry pages, pixel loaders, and OG HTML.
- **Link Cloaking (`is_cloaked`)** — Embeds the destination in an iframe on your short-link domain so the browser address bar shows your branded URL. Human visitors see the iframe; crawlers receive a standalone OG page without iframe/JS redirect.
- **Bot-safe OG pages** — Social crawlers get full meta tags without `meta refresh` or JavaScript redirects, so link previews scrape reliably.
- **OG meta scraper** — SSRF-safe streaming fetch from the destination URL; auto-populates title, description, and image in the Filament form (`GET /short-url/scrape-meta`).
- **OG image pipeline** — Download, WebP conversion (Imagick → GD → original fallback), temp-bucket storage with automatic promotion to `short-urls/og/` on save.
- **`OgMetaPresenter` & `redirect-html` view** — Centralized OG rendering for cloaked links and bot previews.
#### Security
- **Password hashing** — Link passwords are hashed at rest (bcrypt/argon). Legacy plain-text passwords remain verifiable and are re-hashed automatically on save. New model helpers: `hasPassword()` and `verifyPassword()`.
- **API password privacy** — REST responses expose `password_protected: bool` instead of the raw password/hash.
- **Webhook SSRF guard** — Outbound webhook URLs are validated against private IP, localhost, and cloud metadata blocklists before queueing and execution.
- **Bot query hardening** — The `?bot=1` debug parameter is limited to `local`/`testing` environments or a configured `SHORT_URL_BOT_DEBUG_SECRET`.
- **Optional Googlebot IP verification** — Reverse-DNS + forward IP check for Googlebot user agents (`SHORT_URL_VERIFY_GOOGLEBOT_IP`).
#### Analytics & Bot Detection
- **Bot visit exclusion** — Crawlers and preview bots no longer create visit records or increment counters.
- **`POST /links/bulk`** — Bulk create up to 100 links.
- **`PUT /links/upsert`** — Create or update by `external_id` (or `url_key` + `destination_url` match).
- **`GET /links/exists`**, **`GET /links/random`**, **`GET /links/info`** — Slug availability, random link, and lightweight lookup helpers.
- **Tags & Folders API** — Full CRUD at `/api/short-url/tags` and `/api/short-url/folders`.
- **Per-key owner scoping** — `owner_user_id` on API keys limits access to links created by that user (required when `scope_links_to_user` is enabled — see [v5.2.2](#v522)).
- **Link-level UTM params** — `utm_*` and `ref` stored on the link and merged into the destination on redirect.
#### Public Stats
- **Shareable stats endpoint** — `GET` or `POST /short-url/public-stats/{url_key}` (and prefixed `/s/public-stats/{url_key}`) returns aggregated analytics when `public_stats_enabled` is on. **v5.2.3+** adds a public HTML dashboard and Filament **⋮ → Public stats** modal.
- **Password auth** — If `public_stats_password` is set, send the password in the **POST body** or **`Authorization: Bearer …`** header for JSON; HTML uses a password form with session unlock. Passwords in the query string are **rejected** (403).
- **Host scope** — Stats are scoped to the request host (default app domain vs verified custom domain).
#### Security & Reliability Hardening
- **DNS-aware SSRF protection** — Outbound URLs (webhooks, OG scraper, iframe checker redirects) resolve hostnames and block targets that resolve to private/metadata IPs.
- **`OutboundUrl` validation rule** — Reusable SSRF guard for form requests and services.
- **Iframeable redirect re-validation** — Redirect chains during cloaking pre-check are followed manually without `allow_redirects`, re-validating each hop.
- **Safe Browsing fail-closed** — When Safe Browsing is enabled but the API is unreachable, URLs are rejected instead of silently allowed.
- **VPN detection fail-closed** — When `block_with_403` is enabled, API timeout/error treats the client as proxy (403).
- **Custom domain legacy URLs** — `/s/{key}` on a verified custom domain returns **301** to `/{key}`.
- **Global webhook toggle respected** — `global_webhook_enabled` in Settings/config must be on before the global webhook URL fires.
- **`log-error` endpoint hardened** — Authenticated, throttled, and validated; no longer accepts anonymous error dumps.
- **Single-use link fix** — Expired single-use links return `410` without recording a visit or incrementing counters.
- **Redis counter fix** — Buffered counter sync no longer fails when Redis is the cache driver.
#### Settings GUI
- **Analytics & Bot Detection section** — Click deduplication, Googlebot IP verification, and bot debug secret are editable in **Settings → Advanced** (previously `.env`-only).
- **Additive migrations** — `2026_06_08_000001`, `2026_06_09_000001`, `2026_06_10_000001`, and `2026_06_11_000001` — safe upgrade from v5.1.x with no data loss. Cross-database: SQLite, MySQL, PostgreSQL.
- **`domain_scope_id`** — Composite unique `(url_key, domain_scope_id)` so the same slug can exist on the default domain and on custom domains independently.
- **Custom domain URLs** — Links on verified custom domains resolve at `https://domain.com/{key}` (no `/s/` prefix); password auth at `/auth/{key}`; legacy `/s/{key}` → 301 → `/{key}`.
- **Real-time visit limits** — `getRealTimeTotalVisits()` merges DB totals with counter-buffer cache; `max_visits` enforced correctly even when redirect model is cached.
- **Stats accuracy & performance** — Unique visits via `COUNT(DISTINCT ip_hash)`; bot/proxy filtering in human aggregation; daily rollups for all chart dimensions; **`FilteredStatsCollector`** (single raw scan with filters); world map and security widget routed through cached stats layers.
- **API hardening** — DB transactions on bulk/upsert, per-link cache invalidation, scoped `exists`, hashed-only API keys, public stats rate limit + password not in query string.
- **Custom domain cron** — `short-url:verify-custom-domains` re-checks DNS for active domains.
- **Settings** — VPN timeout/TTL in GUI; settings cache TTL 3600s; route cache cleared on prefix change; `set()` surfaces errors to the admin UI.
- **Default queue: `sync`** — No background worker required by default; opt in to `database`/`redis` when you run a worker.
#### Enterprise hardening
- **`Ga4MeasurementProtocolService`** — Centralized GA4 MP with `page_view`, `click`, `short_url_visit`, `session_id`, `timestamp_micros`, `engagement_time_msec`; Measurement ID format validation; debug-collector credential test with real stream ID.
- **Custom domain DNS gate** — Multi A-record + CNAME verification; activation blocked until DNS passes (`enforce_dns_on_activate`); removed `SERVER_ADDR` fallback.
- **API upsert lock** — `lock_url_key` now blocks `url_key` and `custom_domain_id` changes via upsert (parity with panel/API update).
- **Stats reliability fixes** — Cross-filter aggregation, public stats rate limiting, webhook SSRF protection, and Octane-safe memory flushing.
- **Stateless redirect default** — `/s/{key}` without `web` middleware; password unlock on `/s-auth/{key}`.
- **Filament link scoping** — `LinkUserScope` + `scope_links_to_user`.
- **Stateless redirect middleware** — Standard redirect paths (`/s/{key}`) use rate limiting only (no `web` session middleware by default). Password auth uses `/s-auth/{key}` with sessions. HTML interstitials and sync visit tracking add latency.
- **Dynamic Stateful Route Transition** — Password-protected redirects are dynamically routed to a stateful `/s-auth/{key}` route wrapped under the `web` group. Handles session verification, password prompts, brute force rate-limiting, and warning interstitials.
- **Robust Integration Testing** — Refactored the entire package test suite and main application feature tests to accommodate the dual stateful/stateless redirect routing architectures without coverage loss.
- **Dedicated `/stats/live` page** — Live Feed is now a full standalone page at `/{record}/stats/live`, on par with Visit Logs at `/{record}/stats/logs`. Tab navigation is consistent across all three stat sub-pages (Statistics / Live Feed / Visit Logs).
- **Intelligent no-change skip** — Each poll tick now calls `checkForUpdates()`, which performs a single O(1) `MAX(id)` query against the composite index. If the highest visit ID hasn't changed since the last render, `skipRender()` is called and Livewire returns a ~100-byte "nothing changed" response — zero PHP rendering, zero DOM operations.
- **Correct cursor initialization** — `$latestVisitId` is initialized to `-1` (sentinel). After the first `getViewData()` call it is set to the actual `MAX(id)`, so the very first poll does not trigger an unnecessary re-render.
- **Cache key versioned by `latestVisitId`** — Fixed a bug where two concurrent users detecting the same new visit could share a thundering-herd cache entry that didn't include the visit that triggered their render, causing them to permanently miss that visit until the next new visit arrived.
- **3-second thundering-herd cache** — Collapses N concurrent admin users watching the same URL into one DB query per 3-second window. Cache key includes URL id, date range, active filters, and `latestVisitId` version.
- **Country flags via `flagcdn.com`** — Flag images are rendered as `<img src="https://flagcdn.com/h20/{cc}.webp">` instead of emoji. The URL is precomputed once in PHP (`getViewData()`), not per-row in Blade. Alpine.js `x-on:error` handles broken images without CSP-unsafe inline `onerror` handlers.
- **Blade performance** — `time_ago` is precomputed once in PHP using `$visit->visited_at->diffForHumans()` (no `Carbon::parse()` double-instantiation). All per-row presentation values are plain PHP arrays, not Eloquent model calls in Blade.
- **Optimized DB query** — Selects only 12 needed columns (`id`, `visited_at`, `country`, `country_code`, `city`, `browser`, `operating_system`, `device_type`, `referer_host`, `referer_url`, `ip_address`, `is_qr_scan`, `selected_variant`) instead of `SELECT *`. Limit reduced to 25 rows.
- **Folders** — Each short URL can be assigned to one folder. Clicking a folder in the sidebar navigates to a filtered link list showing only that folder's links. Link count displayed per folder.
- **Tags** — Each short URL can have up to 5 tags. Clicking a tag shows a filtered link list. Link counts displayed per tag.
- **Archiving** — Short URLs can be archived instead of permanently deleted. Archived links are hidden from the default list view and can be restored at any time via the Filament panel.
#### REST API
- **Per-key API scopes** — Each API key now supports either `links:read-only` (GET only) or `links:read-write` (full CRUD) scope. Read-only keys return `403 Forbidden` on any write attempt.
- **Per-key rate limiting** — Each API key can have its own individual rate limit (requests per minute), independent of the global route throttle. Configure in **Settings → API & Webhooks → Developer API Keys**.
**Source-Available & Dual-Commercial License — version 1.3.** See the [LICENSE](LICENSE) file for the full text.
Permitted free use covers internal/personal use and single-client freelance deployment within defined limits. Commercial distribution, bundling, high-volume use (>1M qualified events/month), software transfer to third parties, and competing products require a separate written Commercial License from the Copyright Holder.