jeffersongoncalves / laravel-short-url
A Laravel package for creating and redirecting short URLs, with caching and an extensible redirect pipeline.
Package info
github.com/jeffersongoncalves/laravel-short-url
pkg:composer/jeffersongoncalves/laravel-short-url
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/contracts: ^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
Suggests
- geoip2/geoip2: Required to resolve visitor geolocation with the MaxMind GeoIP driver.
- predis/predis: Required for Redis-buffered visit counters when the phpredis extension isn't available.
- stancl/tenancy: Required to resolve the current tenant automatically when short-url.tenancy.enabled is on.
README
Laravel Short URL
Headless URL-shortening engine for Laravel. Zero dependency on Filament — works standalone in any Laravel app via its Facade or console commands.
Why this package
- High throughput. The redirect pipeline is a chain of independent, testable stages (
Illuminate\Pipeline), with the resolved link cached and analytics writes made asynchronous — no external integration failure (GeoIP, Safe Browsing, VPN detection) can ever break a redirect. - Contract-driven. Every swappable piece — analytics driver, conversion API dispatcher, DNS verifier, Safe Browsing checker, VPN detector — is an interface under
src/Contracts/, with a default implementation and extensible registries (AnalyticsDriverRegistry,PixelProviderRegistry,FilterTypeRegistry,ImporterDriverRegistry). - Minimal dependencies. Only
spatie/laravel-package-toolsandilluminate/contractsare required. GeoIP (MaxMind), multi-tenancy (stancl/tenancy) and Redis (predis/predis) are all optional — the package works perfectly without them, each integration guarded byclass_exists/a feature flag. - Multi-language. pt_BR, en and es ship out of the box — no hardcoded strings outside
resources/lang.
Requirements
- PHP 8.3+
- Laravel 12 or 13
Installation
composer require jeffersongoncalves/laravel-short-url
Publish config, migrations and translations:
php artisan vendor:publish --tag="short-url-config" php artisan vendor:publish --tag="short-url-migrations" php artisan vendor:publish --tag="short-url-translations" php artisan migrate
Quick usage
use JeffersonGoncalves\LaravelShortUrl\Facades\ShortUrl; // Create $link = ShortUrl::create(['destination_url' => 'https://example.com/product']); // Fluent $link = ShortUrl::destination('https://example.com/product') ->key('promo25') ->expiresAt(now()->addDays(30)) ->maxVisits(1000) ->password('secret') ->create(); // Resolve $link = ShortUrl::resolve('promo25'); // The ready-to-share URL — custom domain when set, otherwise the app's own host $link->fullUrl(); // https://short.test/promo25
Redirecting itself needs no extra code: any request to GET /{urlKey} already flows through the full pipeline.
Campaign tagging (UTM)
Every link can carry its own utm_source/utm_medium/utm_campaign/utm_term/utm_content — set directly, or from a reusable, tenant-scoped UtmTemplate ("campaign"):
use JeffersonGoncalves\LaravelShortUrl\Models\UtmTemplate; $campaign = UtmTemplate::create(['name' => 'Spring SMS', 'utm_medium' => 'sms', 'utm_campaign' => 'spring-sale']); $link = ShortUrl::destination('https://example.com/product') ->utmTemplate($campaign->id) // fills in unset utm_* fields from the template ->utm(['utm_source' => 'agent-42']) // explicit values always win over the template ->create();
These values are attached to the destination URL on redirect (see strip_utm_from_destination to drop the click's own incoming utm_* first) and become the default attribution recorded on the visit whenever the click itself carries no utm_* of its own — so a link generated specifically for SMS is still correctly attributed even if whoever forwards it doesn't append query params by hand.
Set short-url.utm.required (e.g. ['utm_medium']) to make ShortUrlManager reject creating — or updating — a link that doesn't declare those fields, directly or via a template. Enforced everywhere a link is created (facade, builder, CSV/Bitly import).
Destination types
destination_type is one of single, split, or rules:
// Weighted A/B rotation ShortUrl::create([ 'destination_url' => 'https://example.com/base', // fallback 'destination_type' => 'split', 'rotation_variants' => [ ['url' => 'https://a.test', 'weight' => 50, 'label' => 'A'], ['url' => 'https://b.test', 'weight' => 50, 'label' => 'B'], ], ]); // Conditional targeting, evaluated per request ShortUrl::create([ 'destination_url' => 'https://example.com/base', // used when no rule matches 'destination_type' => 'rules', 'targeting_rules' => [ [ 'conditions' => [ ['type' => 'country', 'value' => 'FR'], ['type' => 'device', 'value' => 'mobile'], ], 'destination' => 'https://example.com/france-mobile', ], ], ]);
A condition's type can be device, platform, browser, country, language, referer, utm_source/utm_medium/utm_campaign, a date/time window, visit_count, vpn, or bot. Conditions default to AND; wrap a group in ['or' => [...]] for OR logic. A rule's destination can itself be a nested split array to combine targeting with rotation, and rotation picks are evaluated with statistical-significance tracking (Z-test) so you can tell when a split has a real winner.
The redirect pipeline
ResolveHost → RateLimit → ResolveShortUrl(cache) → DetectBot → DetectVpnProxy
→ CheckAvailability → RequirePassword → ShowWarning → ResolveDestination
→ BuildFinalUrl → RenderInterstitial → Respond → DispatchTracking
RenderInterstitial only fires when the link has attached retargeting pixels.
Each stage can short-circuit by returning a Response directly (wrong password, destination warning, expired link, blocked VPN, plan limit). The resolved link is cached ({host}:{key}) and invalidated automatically on saved/deleted.
Feature overview
| Area | Description |
|---|---|
| Redirecting | Configurable Base62 keys, blacklist, uniqueness per domain, 301|302|307|308, single_use, max_visits, expiration with a fallback redirect. |
| Analytics | Asynchronous visit tracking (TrackShortUrlVisitJob), fast-path UA parsing, GeoIP (CDN headers / MaxMind / ip-api), bot detection, IP anonymization (IPv4 /24, IPv6 /48), daily aggregation with configurable retention. StatsAggregator breaks visits down by UTM source/medium/campaign, device, browser, OS, country, referer, and more. |
| Targeting | Nested and|or rules by device, platform, browser, country, language, referer, UTM, date/time window, visit count, VPN, bot. Weighted A/B rotation with statistical significance (Z-test). |
| Custom domains | DNS verification (TXT/CNAME/A), per-domain routing, wildcard support, root redirect. |
| Security | Bcrypt password protection, signed-token warning page, Google Safe Browsing (sync or async blocking), VPN/proxy detection (flag or 403 block), rate limiting, full audit trail (before/after). |
| Compliance | Configurable retention (package-wide or per tenant plan), per-subject data export/deletion (LGPD/GDPR), analytics-only mode (no PII stored). |
| External analytics | GA4, Plausible, PostHog, Matomo, Umami, Mixpanel, and Segment built in; AnalyticsDriverRegistry::extend() to add any other provider. |
| Conversion tracking | Server-to-server forwarding to Meta CAPI, Google Enhanced Conversions, TikTok Events API, and LinkedIn CAPI when a conversion is recorded via ConversionApiDispatcher. |
| Alerts | Z-score anomaly detection against a 7-day baseline, notifications via mail, database, broadcast, Telegram. |
| Pixels | Retargeting pixels (Meta, Google Ads, TikTok, GA4) rendered on the interstitial, with an optional consent banner. |
| Organization | Hierarchical folders, tags, reusable UTM templates ("campaigns"), archiving. |
| Import/Export | Built-in CSV importer, Bitly API v4 as the reference per-provider importer, CSV export via CsvLinkExporter. |
| ClickHouse | Alternative VisitRepository driver over ClickHouse's native HTTP interface — same contract, no client library dependency. |
| Multi-tenancy | Fully feature-flagged. Auto-scoped via stancl/tenancy when installed, or a Contracts\TenantResolver binding for any other tenancy system. Configurable plan limits (links_per_month, domains, retention_days) via Contracts\PlanResolver. Custom domain resolution pluggable via Contracts\CustomDomainResolver for apps with existing domain infra. |
Contracts\StatsAggregator::forShortUrls(array $shortUrlIds) builds a breakdown across a set of links — a dashboard overview, a scheduled report. It only does the aggregation math; which links belong in the set is always resolved by the caller through ShortUrl's own tenant-scoped query.
Configuration
Every option is documented inline in config/short-url.php. Main groups:
table_prefix, route, key, redirect, cache, tracking (includes clickhouse), domains, branding, security (password, warning, rate limit, VPN, safe browsing), compliance, audit, analytics, conversions, alerts, notifications, pixels, importers, tenancy.
Settings can also be read/written at runtime via Contracts\SettingsRepository, with a declarative schema (schema()) for building dynamic forms in the UI plugin.
Multi-tenancy without stancl/tenancy
Every tenant-scoped model (ShortUrl, CustomDomain, Folder, Tag, UtmTemplate, and settings) resolves "the current tenant" through a single class, Tenancy\TenantContext. If you have your own tenancy — a custom global scope on your own tenant model, for example — bind Contracts\TenantResolver instead of installing stancl/tenancy:
// App\Providers\AppServiceProvider use JeffersonGoncalves\LaravelShortUrl\Contracts\TenantResolver; public function register(): void { $this->app->bind(TenantResolver::class, function () { return new class implements TenantResolver { public function resolve(): int|string|null { return \App\Models\Tenant::current()?->id; } }; }); }
// config/short-url.php 'tenancy' => ['enabled' => true],
TenantResolver is checked before stancl/tenancy's tenant() helper and before the static current_tenant_id fallback. Once it returns your tenant id, scoping works exactly as it does with stancl.
Plan limits (links_per_month, domains, retention_days via tenancy.plans) work the same way — bind Contracts\PlanResolver to say which plan key a given tenant id is on; with nothing bound, every tenant is on plans.default.
Both are container bindings rather than config Closures because php artisan config:cache can't serialize a Closure — it would throw LogicException: Your configuration files are not serializable. on every deploy that runs it.
Custom domain resolution without short_url_custom_domains
If your app already maps hosts to tenants on its own (a multi-tenant SaaS with per-account custom domains, for example), bind Contracts\CustomDomainResolver instead of registering every domain in short_url_custom_domains:
// App\Providers\AppServiceProvider use JeffersonGoncalves\LaravelShortUrl\Contracts\CustomDomainResolver; use JeffersonGoncalves\LaravelShortUrl\Models\CustomDomain; public function register(): void { $this->app->bind(CustomDomainResolver::class, function () { return new class implements CustomDomainResolver { public function resolve(string $host): ?CustomDomain { $account = \App\Models\Account::whereDomain($host)->first(); if (! $account) { return null; } return (new CustomDomain)->forceFill([ 'id' => $account->id, 'tenant_id' => $account->id, 'is_verified' => true, ]); } }; }); }
The returned CustomDomain doesn't need to be persisted — build a transient instance from your own domain data. When bound, Pipeline\Stages\ResolveHost calls it instead of its own short_url_custom_domains lookup, whenever domains.enabled is true.
Artisan commands
All self-register with the scheduler (packageBooted()), respecting their config toggles:
| Command | Frequency |
|---|---|
short-url:sync-counters |
every minute (when counter buffering is on) |
short-url:aggregate-and-prune |
daily at 02:00 |
short-url:verify-domains |
every 6h |
short-url:check-safe-browsing |
daily |
short-url:detect-anomalies |
hourly |
short-url:send-scheduled-reports |
daily |
short-url:import {driver} {source} |
manual |
aggregate-and-prune prunes each tenant's visit rows against its own plan retention_days when multi-tenancy is enabled, falling back to the package-wide short-url.tracking.retention_days otherwise.
Public surface (contract with the UI plugin)
ShortUrl::create(array $attributes): ShortUrlModel ShortUrl::destination(string $url): ShortUrlBuilder ShortUrl::resolve(string $key, ?string $host = null): ?ShortUrlModel // ShortUrlModel $shortUrl->fullUrl(): string // ready-to-share link (custom domain or app host) // ShortUrlBuilder, in addition to the setters shown above ->customDomain(?int $customDomainId) ->utmTemplate(int $utmTemplateId) ->utm(array $attributes) // utm_source, utm_medium, utm_campaign, utm_term, utm_content // src/Contracts/ VisitRepository, GeoIpDriver, VpnDetectionDriver, AnalyticsDriver, SafeBrowsingChecker, StatsAggregator, TargetingResolver, DnsVerifier, SettingsRepository, ImporterDriver, ConversionApiDispatcher, TenantResolver, PlanResolver, CustomDomainResolver // src/Registries/ FilterTypeRegistry, AnalyticsDriverRegistry, PixelProviderRegistry, ImporterDriverRegistry
Testing
composer test # Pest composer analyse # PHPStan (Larastan) level 6 composer format # Pint
CI runs against PHP 8.4 / Laravel 13 on SQLite, MySQL, and PostgreSQL.
AI-assisted development
This package ships a Laravel Boost skill (resources/boost/skills/short-url-development/) and guideline (resources/boost/guidelines/core.blade.php) — if your project uses Boost, an AI assistant picks these up automatically and already knows the facade, contracts, destination types, campaign tagging, and conventions above.
Security
Found a security vulnerability? See SECURITY.md.
Credits
License
MIT. See LICENSE.md for more information.
