spatie/laravel-url-ai-transformer
Spatie package for transforming URLs and web content with AI. Define Laravel AI agent transformers to extract structured data, generate summaries, or apply custom transformations; results are stored in the database and run via an Artisan command.
Preconditions. v2 requires Laravel 12+ and uses the official Laravel AI package instead of Prism. If you are on Laravel 11 or still on Prism, you want the 1.x line — do not mix. Have an AI provider key configured for Laravel AI before you try anything, because the transformer is just an agent underneath and will fail at the model call, not at the package boundary.
Install sequence.
composer require spatie/laravel-url-ai-transformer
php artisan vendor:publish --tag="url-ai-transformer-migrations"
php artisan migrate
php artisan vendor:publish --tag="url-ai-transformer-config" # verify the tag in the docs
Where to look first, in this order:
config/url-ai-transformer.php — model/provider selection and any fetch or storage settings.src/Transformers/LdJsonTransformer.php — this is your template. Copy its shape, don't read the whole source tree.src/Support/Transform.php — the fluent entry point; confirm exactly how registrations are accumulated (per-request object vs. persistent store) before you assume the Artisan command sees your call.UPGRADE.md — read it even on a fresh install, it tells you what changed and therefore what is likely to change again.First use case — prove the loop in 15 minutes. Pick one public page you control, run the bundled transformer, execute the command, read the row back:
use Spatie\LaravelUrlAiTransformer\Support\Transform;
use Spatie\LaravelUrlAiTransformer\Transformers\LdJsonTransformer;
Transform::urls('https://example.com/blog/my-post')
->usingTransformers(new LdJsonTransformer);
php artisan transform-urls
use Spatie\LaravelUrlAiTransformer\Models\TransformationResult;
$data = TransformationResult::forUrl('https://example.com/blog/my-post', 'ldJson');
Run this in tinker or a throwaway route on local first. What you are validating is not "does AI work" but three concrete things: does the command pick up registrations made outside a request lifecycle, what does a failed transformation leave in the table, and is the command synchronous or does it queue. Those three answers determine everything in the next section.
Centralise registrations, don't scatter them. The naive mistake is calling Transform::urls() from a controller. Put the URL/transformer matrix in one place — a dedicated service provider, a config array, or a small TransformationsRegistry class — so the Artisan command has a single, predictable source of work. If your URL list is dynamic, read it from your own pages/posts table inside that registry and let the package stay dumb.
Fan-out by (url, transformer type). The storage key is (url, transformer-type), so one page can carry a summary, an ld+json blob, and a classification side by side. Lean into that: build a small set of narrow transformers rather than one "do everything" prompt. Narrow agents are cheaper, more testable, and a failure in one doesn't destroy the others.
Custom transformer skeleton. A transformer is a Laravel AI agent: instructions plus, optionally, the content that gets included. The design decision that matters is what you send. Sending raw HTML is expensive and noisy — strip to main content (readability/extraction) before it reaches the agent, and cap the length explicitly. Keep the returned type string stable; it is your read key and effectively a public API for your own code.
Read-through, never block a request. Retrieval should be a plain Eloquent lookup with a sensible fallback:
$ldJson = TransformationResult::forUrl($url, 'ldJson');
if ($ldJson === null) {
// render without enrichment, or dispatch a job to fill it
}
Treat a missing result as normal, not exceptional. Enrichment is a write-time concern; pages render fine without it.
Trigger on publish, schedule for drift. Two complementary patterns:
transform-urls to catch pages whose source changed. Guard it with withoutOverlapping() — a long AI loop plus a scheduler is how you get three concurrent copies of the same command.Wrap it in one adapter. Given 50 stars, zero dependents, and two major versions in the release history, do not let Transform:: and TransformationResult:: appear in twenty files. Put a ContentEnricher service in front of it with your own method names (enrich, getLdJson, refresh). A v3 then costs you one file, not a codebase-wide grep.
Test against fixtures, not the network. Fake the AI provider (Laravel AI ships fakes) and feed a stored HTML file rather than hitting a live URL in CI. Assert on the TransformationResult row and on your fallback path when no row exists — the fallback is the branch that runs in production when the model is down.
Version pinning is not optional here. The 1.x changelog shows Prism moving from ^0.85 to ^0.100 across patch releases, and 2.0.0 swapped Prism for Laravel AI outright and raised the PHP/Laravel floor. Pin to a minor (^2.0) and read every changelog before bumping, or a composer update will hand you a rewrite.
SSRF is a real hole, not a theoretical one. The package fetches URLs server-side. Any code path where a user, an editor, or an imported CSV can influence the URL list hands you an internal-network fetch primitive. Enforce a domain allowlist and reject non-public IPs before the URL reaches the package. This is your job, not the package's.
Fetched content is untrusted input to a model. A page can contain text that instructs your agent to do something else. Keep instructions tight and single-purpose, prefer structured output over free text, and never let transformer output trigger an action (sending mail, mutating records, building queries) without a human or a validator in between.
Persisted AI output is durable AI output. Schema-drifting ld+json or a hallucinated field gets written once and then served forever. Validate before you trust: json_decode with error checking, assert required keys, and validate against a JSON schema if you are emitting to search engines. Keep a refresh path — re-running for the same (url, type) should overwrite, but confirm that behaviour rather than assuming it, since append-vs-overwrite is exactly the kind of semantics that changes between majors.
Record provenance. Store the model name, prompt version, and a timestamp alongside each result (in your own table if the package schema is fixed). Without it, when output quality degrades you cannot tell whether it was the prompt, the model, or the page.
Costs accumulate quietly. Every run is a model call over a full page. Truncate content aggressively, skip URLs whose content hash hasn't changed, and never run the transform command in a tight loop while debugging — use one URL and -v.
Config caching bites. After publishing, php artisan config:clear during development; a stale cached config pointing at the wrong provider produces confusing "no results" symptoms that look like a package bug.
Debugging order of operations. When nothing appears in the table: (1) does the command see your registration at all, (2) does the fetch succeed, (3) does the model call succeed, (4) did the row get written. The package collapses all four into "no result," so log at each boundary yourself rather than guessing.
Extension points worth knowing. Subclassing or composing transformers is cheap — you control the instructions and the included content. The read side (TransformationResult::forUrl) is a thin query; if you need richer lookup (staleness, "latest across types," status filtering) add your own repository method rather than patching the model.
How can I help you explore Laravel packages today?