ahmed-aliraqi / laravel-deep-link
Universal Links, Android App Links and smart landing pages for Laravel: shareable URLs that open the mobile app when it is installed and fall back to the stores when it is not.
Package info
github.com/ahmed-aliraqi/laravel-deep-link
pkg:composer/ahmed-aliraqi/laravel-deep-link
Requires
- php: ^8.2
- illuminate/contracts: ^10.0 || ^11.0 || ^12.0
- illuminate/http: ^10.0 || ^11.0 || ^12.0
- illuminate/routing: ^10.0 || ^11.0 || ^12.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0
- illuminate/view: ^10.0 || ^11.0 || ^12.0
Requires (Dev)
- laravel/pint: ^1.13
- orchestra/testbench: ^8.14 || ^9.0 || ^10.0
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-02 23:04:50 UTC
README
One share URL per entity — https://shop.example.com/products/42 — that:
- opens the mobile app directly when it is installed (iOS Universal Links / Android App Links),
- falls back to a smart landing page when it is not: the page tries the app once via its custom scheme (
acme://products/42) or an Androidintent://URL, then redirects to the App Store / Google Play, - renders rich link previews (Open Graph + Twitter cards) in WhatsApp, X, Telegram, iMessage and friends,
- serves the domain verification files (
apple-app-site-associationandassetlinks.json) dynamically from your configuration — no static files to deploy.
Everything is driven by a single config file.
Table of contents
- How a link resolves
- Requirements
- Installation
- Quick start
- Configuration reference
- Usage
- Customizing the landing page
- Mobile app integration guides
- Where the values come from
- Web server requirements
- Verifying your setup
- Troubleshooting
- Testing
How a link resolves
| Situation | What happens |
|---|---|
| App installed | The OS intercepts the URL and opens the app directly. The web page never loads. |
| App not installed (mobile) | The browser loads the landing page. It tries the app once (custom scheme on iOS, intent:// on Android), then redirects to the store for that device. |
| Desktop | The landing page shows the title, image, description and both store buttons. No redirect. |
| Shared in a chat app | The crawler reads the page's Open Graph tags and renders a preview card. |
| Opened in Safari's address bar | iOS never triggers Universal Links from the address bar — the landing page covers this case too. |
The store fallback is a JavaScript redirect, never an HTTP redirect. That keeps the share URL a 200 response, which both Apple and Google require, and keeps link previews working.
Requirements
- PHP 8.2+
- Laravel 10, 11 or 12
- HTTPS on the production domain (both platforms refuse plain HTTP)
Installation
composer require ahmed-aliraqi/laravel-deep-link
Publish the config file:
php artisan vendor:publish --tag=deep-link-config
Optionally publish the landing view and translations:
php artisan vendor:publish --tag=deep-link-views php artisan vendor:publish --tag=deep-link-lang
Quick start
A shop that shares products and stores, with the app scheme acme://:
1. Declare the paths the app claims in config/deep-link.php:
'scheme' => 'acme', 'paths' => [ '/products/*', '/stores/*', ],
2. Add the app identifiers to .env:
DEEP_LINK_IOS_TEAM_ID=ABCDE12345 DEEP_LINK_IOS_BUNDLE_ID=com.example.acme DEEP_LINK_APP_STORE_URL="https://apps.apple.com/app/acme/id1234567890" DEEP_LINK_ANDROID_PACKAGE=com.example.acme DEEP_LINK_ANDROID_SHA256_FINGERPRINTS="AB:CD:EF:...:89" DEEP_LINK_PLAY_STORE_URL="https://play.google.com/store/apps/details?id=com.example.acme"
The verification files are now served automatically:
GET /.well-known/apple-app-site-associationGET /.well-known/assetlinks.json
3. Register the share routes and return a landing page from your controller:
// routes/web.php use App\Http\Controllers\ShareController; Route::get('products/{product}', [ShareController::class, 'product']) ->whereNumber('product')->name('share.products.show'); Route::get('stores/{store}', [ShareController::class, 'store']) ->whereNumber('store')->name('share.stores.show');
// app/Http/Controllers/ShareController.php namespace App\Http\Controllers; use AhmedAliraqi\LaravelDeepLink\Facades\DeepLink; use App\Models\Product; use App\Models\Store; class ShareController extends Controller { public function product(Product $product) { abort_unless($product->isPublished(), 404); return DeepLink::landing($product) ->title($product->name) ->description($product->summary) ->image($product->image_url); } public function store(Store $store) { return DeepLink::landing($store) ->title($store->name) ->description($store->about) ->image($store->logo_url); } }
Done. url('products/42') is now a share link that opens the app, falls back to the stores, and previews nicely in chat apps.
Configuration reference
| Key | Env | Default | Purpose |
|---|---|---|---|
scheme |
DEEP_LINK_SCHEME |
slug of app.name |
Custom URL scheme, lowercase, without ://. |
paths |
— | [] |
Path patterns the app claims (/products/*). Feeds apple-app-site-association. |
ios.team_id |
DEEP_LINK_IOS_TEAM_ID |
null |
Apple Developer Team ID (10 characters). |
ios.bundle_id |
DEEP_LINK_IOS_BUNDLE_ID |
null |
iOS bundle identifier. |
ios.store_url |
DEEP_LINK_APP_STORE_URL |
null |
App Store link. The Smart App Banner id is parsed from its id123… segment. |
android.package |
DEEP_LINK_ANDROID_PACKAGE |
null |
Android applicationId. |
android.sha256_fingerprints |
DEEP_LINK_ANDROID_SHA256_FINGERPRINTS |
null |
Array, or string separated by commas/newlines. Upper-cased automatically. |
android.store_url |
DEEP_LINK_PLAY_STORE_URL |
null |
Google Play link, also the intent:// browser fallback. |
verification.routes |
— | true |
Auto-register the verification routes. |
verification.legacy_apple_route |
— | true |
Also serve /apple-app-site-association at the root (older iOS). |
verification.middleware |
— | [] |
Middleware for the verification routes. Keep it free of auth and locale redirects. |
landing.view |
— | deep-link::landing |
Default landing view. |
landing.app_name |
— | app.name |
Name shown in the page title and OG tags. |
landing.redirect_delay |
— | 1800 |
Milliseconds before the store fallback fires. |
landing.description_limit |
— | 200 |
Description truncation length (after stripping HTML). |
landing.locales |
— | [] |
When set (e.g. ['en', 'ar']), the landing locale is negotiated from Accept-Language. |
Both verification files return 404 until their values are configured — iOS needs team_id + bundle_id, Android needs package + at least one fingerprint. That is intentional: an incomplete file breaks link verification silently.
Usage
Share URLs
use AhmedAliraqi\LaravelDeepLink\Facades\DeepLink; DeepLink::url('products/42'); // https://shop.example.com/products/42 DeepLink::url($product); // same, from a DeepLinkable model DeepLink::schemeUrl($product); // acme://products/42 DeepLink::intentUrl($product); // intent://products/42#Intent;scheme=acme;package=…;end (null until the package is set)
DeepLink::url() uses Laravel's url() helper, so APP_URL must be the exact production domain (https://shop.example.com). A www. or http:// mismatch makes every shared link skip the app.
The DeepLinkable contract
Implement the contract on anything shareable and use the trait for convenience methods:
use AhmedAliraqi\LaravelDeepLink\Concerns\HasDeepLink; use AhmedAliraqi\LaravelDeepLink\Contracts\DeepLinkable; use Illuminate\Database\Eloquent\Model; class Product extends Model implements DeepLinkable { use HasDeepLink; public function deepLinkPath(): string { return "products/{$this->id}"; } }
Expose the link in your API resources so clients never build URLs themselves:
class ProductResource extends JsonResource { public function toArray($request): array { return [ 'id' => $this->id, 'name' => $this->name, // ... 'share_url' => $this->deepLinkUrl(), ]; } }
The path returned by deepLinkPath() must match one of the patterns in deep-link.paths and one of your share routes — these three always move together.
Landing pages
DeepLink::landing() returns a Responsable, so you can return it straight from a controller. It renders the landing view with platform detection, Open Graph tags, the Safari Smart App Banner, and the open-app-or-store script.
return DeepLink::landing($product) // or DeepLink::landing('products/42') ->title($product->name) ->description($product->summary) // HTML stripped, truncated to 200 chars ->image($product->image_url); // absolute URL
Guard visibility in the controller before returning the page — unpublished content should 404:
abort_unless($product->isPublished(), 404);
The Landing builder API
| Method | Purpose |
|---|---|
title(?string) |
Page <h1>, <title> and og:title. Falls back to the app name. |
description(?string) |
Meta description and og:description. HTML is stripped, text truncated. |
image(?string) |
Preview image and og:image. |
url(?string) |
Canonical URL override. Defaults to the subject's share URL. |
view(string) |
Render a different Blade view for this landing only. |
with(array) |
Extra data passed to the view (useful with custom views). |
render(?Request) |
Returns the View directly if you need it outside a controller response. |
Verification endpoints
| Path | Route name | Returns |
|---|---|---|
GET /.well-known/apple-app-site-association |
deep-link.apple |
iOS Universal Links JSON. 404 until team_id and bundle_id are set. |
GET /apple-app-site-association |
deep-link.apple.legacy |
The same file at the root path, checked by older iOS versions. |
GET /.well-known/assetlinks.json |
deep-link.android |
Android App Links JSON. 404 until the package and a fingerprint are set. |
Generated with the example values:
{
"applinks": {
"apps": [],
"details": [{
"appID": "ABCDE12345.com.example.acme",
"appIDs": ["ABCDE12345.com.example.acme"],
"paths": ["/products/*", "/stores/*"],
"components": [{ "/": "/products/*" }, { "/": "/stores/*" }]
}]
},
"webcredentials": { "apps": ["ABCDE12345.com.example.acme"] }
}
appID + paths is the legacy format for iOS 12 and older; appIDs + components is the current one. webcredentials lets iOS offer the domain's saved passwords inside the app.
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.acme",
"sha256_cert_fingerprints": ["AB:CD:…:89"]
}
}]
The DeepLink facade API
| Method | Returns |
|---|---|
url($subject) |
Absolute share URL. |
schemeUrl($subject) |
acme://products/42. |
intentUrl($subject) |
Android intent:// URL, null until the package is configured. |
storeUrl(Platform|string $platform) |
Store link for ios / android, null otherwise. |
appStoreId() |
Numeric App Store id parsed from the iOS store URL. |
detectPlatform(?string $userAgent) |
Platform::Ios / Platform::Android / Platform::Desktop. |
scheme() / paths() / androidPackage() / androidFingerprints() |
Normalized config values. |
appleAppSiteAssociation() / assetLinks() |
Verification payloads, null until configured. |
landing($subject) |
The Landing builder. |
$subject is always a path string or a DeepLinkable.
Customizing the landing page
Publish the view and edit freely — it is a single self-contained Blade file with no asset pipeline:
php artisan vendor:publish --tag=deep-link-views
# → resources/views/vendor/deep-link/landing.blade.php
The view receives: meta (title, description, image, url), appName, platform (a Platform enum: $platform->isIos(), isAndroid(), isDesktop(), isMobile()), schemeUrl, intentUrl, target (the URL the page should try first), storeUrl (for the visitor's platform), appStoreUrl, playStoreUrl, appStoreId and redirectDelay.
Or use a different view for one landing only:
return DeepLink::landing($product) ->view('share.product') ->with(['product' => $product]);
Translations ship in English and Arabic. Publish to override or add languages:
php artisan vendor:publish --tag=deep-link-lang
# → lang/vendor/deep-link/{en,ar}/landing.php
To localize the landing page from the visitor's Accept-Language header, list the supported locales:
'landing' => [ 'locales' => ['en', 'ar'], ],
Mobile app integration guides
The backend half is only half of the story — the app must claim the domain and handle incoming URLs:
- Flutter —
app_links+share_plus, manifest and entitlements, a sealed-class URI parser, router wiring. - Android (native) — verified intent filters, handling
onCreate/onNewIntent, sharing,adbverification commands. - iOS (native) — Associated Domains, SwiftUI
onOpenURLand UIKit scene delegate handling, Smart App Banner notes. - React Native —
LinkingAPI and React Navigationlinkingconfig.
All four guides use the same example app (Acme, scheme acme://, paths /products/* and /stores/*), so they can be handed to a mobile team as-is and adapted.
Where the values come from
| Value | Where to find it |
|---|---|
| Apple Team ID | developer.apple.com → Membership details. |
| iOS Bundle ID | Xcode → target → Signing & Capabilities. |
| Android package name | applicationId in android/app/build.gradle. |
| SHA-256 fingerprints | Play Console → App integrity → App signing key certificate — plus the upload and debug keys. |
| Store URLs | The public App Store / Google Play listing links. |
Warning — Play App Signing. Builds installed from Google Play are signed with Google's key, not your upload key. If only the upload key fingerprint is registered, links work in local builds but fail for real users. Register both.
Print the debug key fingerprint:
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey \ -storepass android -keypass android | grep SHA256
Web server requirements
- HTTPS with a valid certificate on the exact domain in
APP_URL. - No redirects on the verification paths. Apple and Google do not follow them: no HTTP→HTTPS hop, no
wwwhop, no locale or trailing-slash redirect. - The paths must reach Laravel. Many nginx configs deny dotfiles with
location ~ /\. { deny all; }, which also blocks/.well-known/. - No bot challenge (Cloudflare "Under Attack" mode, WAF JS challenge) in front of these paths.
- No static files with the same names under
public/— the web server would serve them instead of the dynamic response.
# Must come before any rule that denies dotfiles location ^~ /.well-known/ { try_files $uri /index.php?$query_string; } location = /apple-app-site-association { try_files $uri /index.php?$query_string; } # Hide other dotfiles as before location ~ /\.(?!well-known) { deny all; }
Verifying your setup
# Expect 200, application/json, and no Location header curl -sI https://shop.example.com/.well-known/apple-app-site-association curl -sI https://shop.example.com/.well-known/assetlinks.json # What Apple's CDN serves to devices (can lag behind the origin by up to a day) curl -s https://app-site-association.cdn-apple.com/a/v1/shop.example.com # Google's view of the Android statement curl -s "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://shop.example.com&relation=delegate_permission/common.handle_all_urls" # Landing page as an iPhone — should contain acme://products/42 curl -s -A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)" \ https://shop.example.com/products/42 | grep acme://
Troubleshooting
| Symptom | Likely cause |
|---|---|
Verification file returns 404 |
Config incomplete (iOS needs Team ID + Bundle ID, Android needs package + fingerprint), or nginx denies /.well-known/. |
| Android shows the "Open with" chooser | Verification failed. Check the fingerprint matches the key that signed the installed build, then adb shell pm verify-app-links --re-verify com.example.acme. |
| Works in debug, not from Play | Only the upload key is registered. Add the Play App Signing fingerprint. |
| iOS always opens Safari | Apple's CDN still has an old or missing file — check the CDN URL above and reinstall the app. Links typed into Safari's address bar never open the app. |
| Share URL has the wrong host | APP_URL mismatch, or a cached config. Run php artisan config:clear. |
| No preview image in chat apps | The image URL is missing or not publicly reachable. Chat apps also cache previews per URL. |
Testing
composer test
composer lint
Changelog
See CHANGELOG.md.
License
MIT. See LICENSE.md.