victorio / filament-public-links
Expiring, revocable public links to any Eloquent model for Filament: share a record without a login, with an access log, a branded public page and a relation manager.
Package info
github.com/victorio-dev/filament-public-links
pkg:composer/victorio/filament-public-links
Requires
- php: ^8.3
- filament/filament: ^4.0|^5.0
- illuminate/contracts: ^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
- victorio/support: ^0.3
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-22 07:29:12 UTC
README
Expiring, revocable public links to any Eloquent model — your customer opens the quote, no login needed — with an access log, a branded public page and a Filament UI to manage them.
- Hash-only token lookup, expiry, revocation, usage limits — for models with int, ULID or UUID keys
- A relation manager and a standalone action for Filament 4 and 5
- A branded public layout with light and dark mode; 404 and 410 pages included
- An access log with hashed IPs and automatic pruning; events for every step
- Your code decides what a link shows or does: register a handler per model and purpose
Requirements
- PHP 8.3+
- Laravel 12 or 13
- Filament 4 or 5
Installation
composer require victorio/filament-public-links
php artisan filament-public-links:install # publishes config and migrations, offers to migrate
Non-interactive runs (-n, CI, deploys) do not migrate: run php artisan migrate afterwards.
Access log rows are pruned by Laravel, so schedule model:prune (retention: 90 days by default):
// routes/console.php use Illuminate\Support\Facades\Schedule; use Victorio\PublicLinks\Models\PublicLinkAccess; Schedule::command('model:prune', ['--model' => [PublicLinkAccess::class]])->daily();
Quick start
1. The model:
// app/Models/Document.php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Victorio\PublicLinks\Concerns\HasPublicLinks; class Document extends Model { use HasPublicLinks; }
2. What the link shows, registered in a service provider's boot():
use App\Models\Document; use App\PublicLinks\ShowDocument; use Victorio\PublicLinks\Facades\PublicLinks; PublicLinks::handle(Document::class, 'view', ShowDocument::class);
3. Issue a link and send it:
use App\Models\Document; $document = Document::findOrFail($id); $url = $document->publicLink('view')->expiresIn(days: 14)->maxUses(5)->issue()->url;
A handler renders the page; the package has already checked the token, expiry, revocation and usage limit, written the access row and loaded the model:
// app/PublicLinks/ShowDocument.php namespace App\PublicLinks; use Symfony\Component\HttpFoundation\Response; use Victorio\PublicLinks\Data\PublicLinkVisit; use Victorio\PublicLinks\Handlers\PublicLinkHandlerBase; final class ShowDocument extends PublicLinkHandlerBase { public function show(PublicLinkVisit $visit): Response { return response()->view('documents.public', ['document' => $visit->linkable, 'link' => $visit->link]); } }
Its view uses the branded layout — pass the link, so the page gets its brand and language:
{{-- resources/views/documents/public.blade.php --}} <x-filament-public-links::layout :link="$link" :page-title="$document->title"> <x-slot:title>{{ $document->title }}</x-slot:title> <p>{{ $document->body }}</p> <x-slot:footer>Questions? Reply to the e-mail this link came in.</x-slot:footer> </x-filament-public-links::layout>
page-title goes into the browser tab (with your brand name), the title slot is the page
heading, the content is yours (escape with {{ }}), and footer adds an optional line.
Handlers that accept actions (POST /p/{token}/{action}, CSRF-protected) implement
HandlesPublicLinkActions; a handler decides whether an open or only a successful action counts
as a use.
|
|
Filament
Add the relation manager to any resource whose model uses HasPublicLinks:
use Victorio\PublicLinks\Filament\RelationManagers\PublicLinksRelationManager; public static function getRelations(): array { return [PublicLinksRelationManager::class]; }
It lists the record's links, creates new ones (purpose, expiry, usage limit) and shows the URL
once, copies an existing link's URL on demand, revokes one or several links, and shows each link's
access log. Prefer a single button? IssuePublicLinkAction::make() works as a page action or a
table row action, with the same form and URL modal. To link another model than the record (a
copy, a sealed version), give it ->issueUsing() and ->linkOwnedUsing(); ->fields([...])
picks the form fields, ->modalHeading() and ->urlModalHeading() the two headings. To ship
such a variant as your own action class, extend IssuePublicLinkAction and call parent::setUp()
first in your setUp().
|
|
Permissions follow the owner model's policy: view to see its links and their access log,
update to create, copy and revoke them; for a model without a policy, the panel's
strictAuthorization() setting decides, as everywhere in Filament. With Filament tenancy, only the
current tenant's links are listed and new links belong to the current tenant.
The panel plugin is optional — register it only to change the defaults:
use Victorio\PublicLinks\PublicLinksPlugin; $panel->plugins([ PublicLinksPlugin::make() ->expiryOptions([1, 7, 30]) // days offered in the form ->defaultExpiry(7) ->purposeLabels(['view' => 'View document']) // wins over every other purpose label ->dateTimeFormat('d/m/Y H:i'), // every time in the table, log and modal ]);
Purposes get readable labels on their own: the label given at registration
(PublicLinks::handle(Document::class, 'accept', AcceptDocument::class, 'Accept the document'), plain text
or a translation key), else the package's translation for view, accept and download, else the
name itself ("sign-contract" becomes "Sign contract"). Times are shown in Filament's display
timezone. When links change outside the relation manager it refreshes on its own; after changing
links in your own code, dispatch PublicLinksRelationManager::LINKS_CHANGED from the Livewire
component.
Appearance
The links table follows Filament's usual look: labelled row actions. Change it on the plugin, or
change any action of the package wherever it appears with Filament's configureUsing():
use Filament\Actions\Action; use Victorio\PublicLinks\Filament\Actions\RevokePublicLinkAction; use Victorio\PublicLinks\PublicLinksPlugin; $panel->plugins([ PublicLinksPlugin::make() ->rowActions('icons') // 'labels' (default), 'icons' (label as tooltip) or 'group' (one menu) // Keyed by name; return them in your order, leave some out, change or add your own. ->modifyRowActionsUsing(fn (array $actions): array => [ $actions['copyPublicLink'], $actions['revokePublicLink'], Action::make('resend')->icon('heroicon-o-paper-airplane')->action(/* … */), ]) ->modifyHeaderActionsUsing(fn (array $actions): array => [$actions['issuePublicLink']->label('Share')]) ->modifyToolbarActionsUsing(fn (array $actions): array => $actions), // bulk actions ]); // In a service provider's boot(), for every table and page: RevokePublicLinkAction::configureUsing(fn (RevokePublicLinkAction $action) => $action->color('warning'));
The actions are public classes — IssuePublicLinkAction, CopyPublicLinkAction,
ViewAccessLogAction, RevokePublicLinkAction, RevokePublicLinksBulkAction — so they also work
in your own tables of links. Icons are heroicons; hidden actions stay hidden, and refused, in
every layout.
Customization
Start at the top of this list and stop at the first step that does the job.
-
Config —
config/filament-public-links.php: expiry defaults and limits, rate limits, route prefix and domain, access log retention, response headers, and the brand of the public pages:'branding' => [ 'name' => 'Acme Ltd', // null = config('app.name') 'logo_url' => 'https://cdn.acme.test/logo.svg', 'primary_color' => '#1d4ed8', // #rgb or #rrggbb; text colour is picked for contrast 'footer_text' => 'Acme Ltd · 1 Main Street', 'contact_email' => 'links@acme.test', // offered on the "link no longer available" page 'show_powered_by' => false, ],
Admin-side times use the package's translated format (en
M j, Y H:i, huY. M j. H:i);PublicLinksPlugin::make()->dateTimeFormat('d/m/Y H:i')sets your own for all of them. -
Translations — every user-facing string, in English, Hungarian, German, Spanish, French, Italian, Dutch and Brazilian Portuguese:
php artisan vendor:publish --tag="filament-public-links-translations". English and Hungarian are checked by hand; the other six are machine-assisted — corrections are very welcome (see CONTRIBUTING.md). -
Swappable classes — brand per tenant with a resolver bound in
branding.resolver; it receives the link (null on the 404 page) and returns aBranding(example below). The models are swappable too (models.link,models.access). -
Views — last resort.
php artisan vendor:publish --tag="filament-public-links-views".A published view is a copy: it no longer receives the package's fixes and new features. Re-check it after every upgrade, and prefer the steps above whenever they can do the job.
A per-tenant branding resolver:
use App\Models\Team; use Victorio\PublicLinks\Contracts\BrandingResolver; use Victorio\PublicLinks\Data\Branding; use Victorio\PublicLinks\Models\PublicLink; final class TenantBranding implements BrandingResolver { public function resolve(?PublicLink $link): Branding { $team = Team::find($link?->tenant_id); return Branding::make(name: $team->name ?? config('app.name'), logoUrl: $team?->logo_url); } }
Languages
A link's public pages are shown in the link's own language when you set one, otherwise in the
visitor's browser language (among locales.supported), otherwise in the app locale. The 404 page
has no link and starts at the browser. The handler runs with that locale already set.
$document->publicLink('view')->locale($document->locale)->issue(); // e.g. 'de'
The Filament form offers a language, proposing the record's own when the model says it, else the app's:
public function publicLinkLocale(): ?string { return $this->locale; // on a model using HasPublicLinks }
locales.accept_language = false ignores the browser. Right-to-left languages (locales.rtl:
Arabic, Hebrew, Persian, Urdu, …) get dir="rtl", and the layout uses logical CSS properties.
Events
| Event | When |
|---|---|
PublicLinkCreated |
a link was issued |
PublicLinkOpened |
a link was opened (every time) |
PublicLinkFirstOpened |
a link was opened for the first time (once per link) |
PublicLinkActionPerformed |
a handler action succeeded |
PublicLinkRevoked |
a link was revoked (once per link) |
PublicLinkExhausted |
the use that reached the usage limit (once per link) |
All live in Victorio\PublicLinks\Events and carry the PublicLink. There are no built-in
notifications; "tell the sales rep when the customer first opens the quote" is one listener on
PublicLinkFirstOpened.
Security
- Tokens: 32 random bytes. The database holds their SHA-256 hash, used for the lookup and
compared in constant time, and an encrypted copy (your app key) so admins can copy a link again;
never the plain token. Rotating
APP_KEYwithoutprevious_keyskeeps links working but makes them un-copyable. - Responses send
Referrer-Policy: no-referrer,X-Robots-Tag: noindex, nofollowandCache-Control: no-store. URLs contain only the token, never a model id. - Access log: every request is logged with an HMAC of the IP and a shortened user agent, never the raw IP; rate-limited requests are not logged. Rows are pruned after 90 days.
- Rate limits per IP, with a stricter one for unknown tokens.
- Your web server's access log contains the public URLs — configure its retention accordingly.
Records of your own that should match the access log (who accepted, from which request) can
store the same IP hash: IpHasher::forRequest($request) returns exactly the ip_hash of that
request's access row, and handlers receive the row itself as $visit->access.
use Victorio\PublicLinks\Support\IpHasher; $hash = IpHasher::forRequest($request); // HMAC-SHA256, the access log's key
Please report vulnerabilities privately, as described in SECURITY.md.
Testing
composer test
Changelog
See CHANGELOG.md. Breaking changes are listed under "Breaking" and only land in a new major version (or a new minor version before 1.0).
License
MIT — see LICENSE.md. Part of the Victorio suite of Filament plugins; the paid packages build on this one.