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
Requires
- php: ^8.3
- illuminate/contracts: ^12.0|^13.0
- jeffersongoncalves/laravel-visitor-fingerprint: ^1.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
- endroid/qr-code: Required to generate QR codes for short urls (^6.0).
- geoip2/geoip2: Required to resolve visitor geolocation with the MaxMind GeoIP driver (see jeffersongoncalves/laravel-visitor-fingerprint).
- 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.
Provides
None
Conflicts
None
Replaces
None
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), Redis (predis/predis) and QR codes (endroid/qr-code) 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.
Batch resolve/create
Routing many destination URLs through short links at once — e.g. rewriting every outbound link in a large rendered document — shouldn't pay a cache read + DB round trip per link. resolveMany() dedupes the input, does one batched cache read plus one whereIn() query for whatever the cache missed, and only pays the real create() cost for genuinely new destinations:
$links = ShortUrl::resolveMany([ 'https://example.com/product-a', 'https://example.com/product-b', ]); // ['https://example.com/product-a' => 'https://short.test/aBc1234', 'https://example.com/product-b' => 'https://short.test/xYz9876']
A destination that fails to mint a key (plan limit, required UTM missing, ...) falls back to itself in the result instead of losing the rest of the batch.
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).
QR codes
Requires the optional endroid/qr-code package (^6.0):
composer require endroid/qr-code
$link->qrCode()->svg(); // string $link->qrCode()->png(); // string $link->qrCode(size: 400, margin: 20)->dataUri(); // data:image/png;base64,... $link->qrCode()->dataUri('svg'); // data:image/svg+xml;base64,...
Encodes the link's fullUrl(). Without the package installed, each call throws QrCodeGeneratorMissing — never breaks link creation or redirects, only the QR call site itself.
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. |
| QR codes | $shortUrl->qrCode()->svg()/->png()/->dataUri() via the optional endroid/qr-code (^6.0) package. Size/margin configurable via method args. |
| 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.
Device/browser/OS parsing, bot detection, IP anonymization, GeoIP resolution, and VPN/proxy/Tor detection are provided by jeffersongoncalves/laravel-visitor-fingerprint — configure those via its own config/visitor-fingerprint.php (geoip.driver, vpn_detection.driver, hash_salt, ...), not short-url.tracking.*. short-url.security.vpn_detection.mode (off/flag/block) stays here since it's this package's own enforcement policy.
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 scheduling.aggregate_and_prune.time (default 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.
aggregate-and-prune, detect-anomalies, and send-scheduled-reports run unconditionally by default (unlike the others, which are already gated behind a feature toggle) but can each be turned off — e.g. to run them from a different process, or on a different schedule — via short-url.scheduling.{command}.enabled (SHORT_URL_SCHEDULE_AGGREGATE_AND_PRUNE, SHORT_URL_SCHEDULE_DETECT_ANOMALIES, SHORT_URL_SCHEDULE_SEND_SCHEDULED_REPORTS). aggregate-and-prune's time is also configurable via short-url.scheduling.aggregate_and_prune.time (SHORT_URL_SCHEDULE_AGGREGATE_AND_PRUNE_TIME).
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 ShortUrl::resolveMany(array $urls): array // destination url => full short url (or the destination url unchanged on failure) // ShortUrlModel $shortUrl->fullUrl(): string // ready-to-share link (custom domain or app host) $shortUrl->qrCode(int $size = 300, int $margin = 10): QrCodeGenerator // ->svg() / ->png() / ->dataUri() // 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, AnalyticsDriver, SafeBrowsingChecker, StatsAggregator, TargetingResolver, DnsVerifier, SettingsRepository, ImporterDriver, ConversionApiDispatcher, TenantResolver, PlanResolver, CustomDomainResolver // Device/GeoIP/VPN/GDPR — provided by jeffersongoncalves/laravel-visitor-fingerprint VisitorFingerprint\Contracts\GeoIpDriver, VisitorFingerprint\Contracts\VpnDetectionDriver // 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 Vulnerabilities
Found a security vulnerability? See SECURITY.md.
Credits
License
MIT. See LICENSE.md for more information.
