public function hasAssets(string $directory = 'dist', bool $mirror = true, array $entries = []): staticpublic function hasViteAssets(array $entries, ?string $base = null): static
$packager ->name('Blog') ->hasAssets(entries: [ 'css/blog.css', 'js/blog.js', ]);
acme/blog/└── dist/ ├── css/blog.css └── js/blog.js
<head> @packageStyles('blog')</head><body> @packageScripts('blog')</body>
One call gives you four things: two publish tags, a self-maintaining mirror that keeps
public/vendor/blog in step with dist/ without anyone running a command, and tags a template
can ask for by name. hasViteAssets() adds the fifth — the same
declaration resolving through the consuming application's own Vite build when that application
wants the package inside it.
Publishing
php artisan vendor:publish --tag=blog::assetsphp artisan vendor:publish --tag=laravel-assets --force
Both tags publish the same files to public/vendor/{short-name}/, preserving the directory
structure:
public/vendor/blog/├── css/blog.css└── js/blog.js
The second tag is the interesting one. laravel-assets is what the Laravel application skeleton
already runs from Composer's post-update-cmd:
"post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force"]
It is the same hook Horizon, Telescope and Nova rely on, and it means one command republishes
every installed package's assets after every composer update — something a per-package tag
cannot express. Laravel accumulates publish groups per path, so registering both tags publishes the
same files and leaves an untagged vendor:publish unaffected.
The asset mirror
Added in 2.3.0, which has since been withdrawn — in practice, available from 2.4. Publishing solves the deploy case. The mirror solves the case where nobody published — which, for a package whose CSS is not optional, is the difference between working and not.
Support\PublishedAssets is a container singleton shared by every package in the application. Ask
it for a URL and it returns one, mirroring the package's directory first if anything is missing or
out of date:
use NyonCode\LaravelPackageToolkit\Support\PublishedAssets; // An og:image the layout composes itself — not a stylesheet or a script, so not// something a directive renders, and the mirror is how it reaches `public/`.$url = app(PublishedAssets::class)->url( 'blog', __DIR__.'/../dist/img/og.png',); // https://example.test/vendor/blog/img/og.png?id=1754640000
That example is deliberately not a stylesheet
This is the layer, not the way to reach it. A file a template renders as a <link> or a <script>
belongs in hasAssets(entries: [...]) and is asked for by its key —
@packageAssetUrl('blog', 'css/blog.css'), or app(PackageAssets::class)->url('blog', 'css/blog.css').
That resolves the same mirror underneath and additionally goes through the
application's Vite build when that build covers the entry.
PublishedAssets directly is for what a directive cannot render: an image or font the template
composes itself, an asset registered by an application rather than a package, and isStale(), which
has no counterpart on PackageAssets. Building a tag around it is
a pattern with no remaining use.
Why files, not a route
Assets are served as real files under public/, never through a route, and that is a deliberate
constraint rather than a preference. Route delivery only works when the request reaches PHP, and a
very common nginx layout answers .js from a try_files $uri =404 block that never forwards it —
the same block that 404s Livewire's own /livewire/livewire.js. On shared hosting that block is
frequently not the application's to change. A delivery mode that depends on it is not a delivery
mode; it is a support ticket. A file that exists is served by every web server configuration there
is.
How the sync behaves
Lazy. Nothing is copied during register() or boot(). The first asset of a package to resolve
a URL in a request triggers the sync. A queue worker, an API route and an artisan command that
will never emit a <script> pay nothing.
Incremental. Each shipped file is compared against its published counterpart by mtime, and only
what is missing or older is copied. In steady state that is a handful of stat calls and no writes;
after an upgrade it is one copy per changed file, on one request.
Complete. The whole directory is walked, not just the file that was asked for. A code-split entry
point imports ./chunk-a1b2c3.js, which the browser fetches directly and PHP is never asked to
resolve — a mirror driven only by resolved URLs would leave that chunk behind and break the bundle.
Atomic. Copies land through a temporary file and rename(). A request fetching a file while
another is mid-copy reads either the whole old one or the whole new one — never a truncated bundle,
which would fail as a syntax error and take everything in it down.
Once per request per package. The singleton memoises both the sync attempt and every resolved URL.
Cache busting
The returned URL carries ?id=<mtime of the published copy>, and the sync sets that mtime to the
moment of the copy. That is what keeps Livewire's data-navigate-track meaningful: Livewire
full-page-reloads a wire:navigate visit when a tracked asset's query string changed, so an upgrade
is picked up instead of running new markup against a file the browser already cached.
@packageStyles and @packageScripts put data-navigate-track="reload" on every tag they render
for that reason. Writing the tags by hand, it is on you:
<link rel="stylesheet" href="{{ $cssUrl }}" data-navigate-track="reload"><script src="{{ $jsUrl }}" data-navigate-track="reload" defer></script>
When public/ is not writable
A read-only container, Vapor, a hardened deployment. Nothing throws:
$assets = app(PublishedAssets::class); $url = $assets->url('blog', $path); // null if nothing is published if ($url === null) { // Fall back to however you served it before — a CDN, an inline <style>.} if ($assets->isStale('blog', $path)) { logger()->warning('Blog assets are older than the installed package. Run vendor:publish.');}
An older published copy is still preferred over nothing, and isStale() names that situation so you
can warn about it — a stale copy being served is a production condition worth surfacing, not a
silent one.
Keeping the tag: hasAssetFallback()
Added in 2.4.2. The code above is for markup you compose yourself. For a declared entry the
renderer has the same problem and one more constraint: there is nothing to point the tag at, so
@packageAssets drops it. That is right for an entry the application chose not to build, and wrong
for the entry that is your package's only copy — the page loses its stylesheet or its behaviour with
nothing in the markup, the log or the console to say why, on exactly the deployments least likely to
go looking.
If your package also serves its assets from a route of its own, say so and the tag survives:
$packager ->hasAssets(entries: ['js/blog.js']) ->hasAssetFallback(fn (string $file): string => route('blog.asset', ['file' => $file]));
The resolver is handed the entry's path inside the asset directory and the package's short name, and
is reached only after both the mirror and public/vendor/{short-name} came back with nothing — in a
normal deployment it is never called at all. Return null and the tag is dropped as before.
It owns the whole URL it returns, cache-busting query string included. The ?id= the renderer
appends elsewhere is the published copy's mtime, and the point of being here is that there is no
published copy; only you know what your route varies on.
What you get back for declaring it is the tag itself — type="module" or the defer that
classic() implies, your declared attributes, data-navigate-track="reload" and the application's
CSP nonce. That is the whole reason to declare a fallback rather than hand-write a <script> beside
the directive, which is the mistake @packageAssets exists to remove.
resolution() reports such an entry as fallback, so a deployment serving from the route is
distinguishable from one serving nothing.
A fallback is not a substitute for publishing
It is the same file, served the slow way — through PHP, past the middleware stack, with no
try_files shortcut. Where public/ can be written, the mirror or vendor:publish is still what
should be serving it.
Opting out
$packager->hasAssets(mirror: false);
Keeps both publish tags, skips the mirror registration entirely. Use it when your deploy publishes explicitly and you would rather the first request did no filesystem work at all.
flush() — for long-lived workers
Added in 2.4.0. The singleton is scoped to a request. Under a long-lived worker it outlives one, and two things go wrong:
- the per-request sync marks limit the mirror to a single attempt per worker lifetime, so a published copy deleted underneath a running worker is never put back;
- every resolved URL keeps emitting the
?id=<mtime>of the release the worker booted on — the exact query string Livewire watches to notice a deploy.
use NyonCode\LaravelPackageToolkit\Support\PublishedAssets; public function boot(): void{ $this->app->terminating(function () { if (app()->bound(PublishedAssets::class)) { app(PublishedAssets::class)->flush(); } });}
flush() clears the resolved URLs and the sync marks. It deliberately keeps the declared asset
directories: providers register those from register(), once per worker boot and not per request,
and clearing them would drop the resolver back to inferring a directory from the asset path — which
only works for a package whose asset directory happens to be named dist.
Octane is not a supported target
The toolkit has never been developed against a long-lived worker. flush() exists because a
consumer committed to one, not because the toolkit targets them.
Rendering them in a template
Added in 2.4.0; discovery in 2.4.1. Name the files a template renders — or name nothing and let them be discovered — and the toolkit registers the directives that render them:
$packager->hasAssets(entries: ['css/blog.css', 'js/blog.js']);
@packageAssets('blog') {{-- everything, stylesheets first --}}@packageStyles('blog') {{-- only the <link> tags --}}@packageScripts('blog') {{-- only the <script> tags --}}@packageScripts('blog', 'js/blog.js') {{-- only the entries you name --}}@packageAssetUrl('blog', 'js/blog.js') {{-- the bare URL, for your own markup --}}
<link rel="stylesheet" href="/vendor/blog/css/blog.css?id=1754640000" data-navigate-track="reload"><script src="/vendor/blog/js/blog.js?id=1754640000" type="module" data-navigate-track="reload"></script>
Every path is relative to the asset directory and is checked at registration — a typo throws
where it was declared, not as a 404 in the browser six screens later. A path that does not exist
throws FileNotFoundException, naming both the entry and the directory it was looked for in.
Naming no package renders every one
Added in 2.4.2. Drop the short name and the directive renders every package that declared entries, in the order their providers handed them over:
@packageAssets {{-- every package, stylesheets first --}}@packageStyles@packageScripts
This is the form an application's layout wants. A layout that names its packages is a layout
that has to be edited every time one is installed or removed, in every file that has the line —
and package:discover does not help, because it discovers providers while the template still
names packages by hand. One line says everything, and keeps saying it.
Stylesheets lead across the whole set, not within each package: the aggregate renders one
document's <head>, so a package whose provider booted third is no reason for its stylesheet to
land behind the second package's scripts. Everything else is per entry as before — each package's
classic(), its attributes, and its own Vite resolution.
They lead within each of the two halves, not across the seam between them. What the application
built is emitted as one Vite block — preloads, stylesheets, scripts, in Vite's own order — and that
block comes first, so a script the application built precedes a stylesheet that fell back to the
shipped copy. Interleaving the two would mean one Vite call per entry and giving up the single set
of preloads, to reorder a deferred module against a <link> the browser fetches without waiting for
it anyway.
@packageAssetUrl keeps both arguments. It answers with one URL, and there is no such thing as
the URL of every package.
Name them when the placement differs
An application that wants only some of its packages in a particular place still names them —
@packageStyles('blog') in <head> and @packageScripts('blog') before </body>. The aggregate
is the default, not the only form.
Naming nothing discovers them
Name no entries and the asset directory answers for itself, the way hasRoutes() and hasViews()
already discover theirs:
$packager->hasAssets();
dist/├── css/blog.css → <link rel="stylesheet">└── js/blog.js → <script type="module">
Discovery looks in the directory hasAssets() was given — hasAssets('public') discovers
public/, not dist/ — at its root and in its css/ and js/ subdirectories, and registers the
stylesheets and scripts it finds there, alphabetically. Extensions are an allowlist
(css, scss, sass, less, styl, pcss, js, mjs, cjs), so the source maps, fonts,
images and manifest.json sharing that directory are passed over rather than turned into <script>
tags.
It is not a recursive walk, and that is the point. A code-split build writes its chunks to a
subdirectory of its own — assets/ by default — and a chunk is imported by an entry point, not
loaded beside it. Giving one its own <script> runs the module a second time, in the wrong order,
for no benefit. Stopping at three directories leaves such a build alone:
dist/├── assets/blog-DkS9x2.js ✗ a chunk, and not discovered├── assets/vendor-a91f3c.js ✗├── css/blog.css ✓└── js/blog.js ✓
Naming any entry replaces discovery outright — the two do not merge — which is also how the two things a directory listing cannot answer get said:
// A code-split build: name the entry points, leave the chunks to the bundler.$packager->hasAssets(entries: ['css/blog.css', 'js/blog.js']); // A discovered script is a module, because that is what a build produces. An IIFE says so.$packager->hasAssets(entries: [ 'css/blog.css', Asset::make('js/blog-legacy.js')->classic(),]);
What it costs
Discovery runs where hasAssets() is called — while the packager is being configured, so once per
boot, alongside the eager validation an explicit list already gets. That is up to three directory
listings and no file reads, but unlike the mirror it is not deferred until
something renders: a queue worker that will never emit a <script> still pays for it. Naming the
entries explicitly is the way to skip it, and for a package whose dist/ is large that is the
better declaration anyway.
Order matters, and the error says so. hasAssets() is what establishes the asset directory, so a
shipped file declared before it has nowhere to be resolved against and throws
PackageConfigurationException:
$packager ->hasViteAssets(['resources/js/blog.js' => 'js/blog.js']) ->hasAssets(); ->hasAssets() ->hasViteAssets(['resources/js/blog.js' => 'js/blog.js']); // Asset [js/blog.js] needs an asset directory. Call hasAssets() before declaring it.
The directives take the short name rather than being generated per package (@blogStyles). A
generated name exists only when that package is installed, cannot be grepped for, and collides
silently with another package's; the short name is already how the toolkit namespaces views,
translations and publish tags.
What the hand-written tag misses
On 2.3.0 the mirror existed and the directives did not, so a package's layout had one way to reach a URL and built the tag around it:
<script src="{{ app(PublishedAssets::class)->url('blog', $js) }}"></script>
That release has been withdrawn, which retires the pattern with it: from 2.4 there is no version where this is the only option, so it is not a trade-off to weigh — it is code to replace. It is written up here because it renders fine, which is what keeps it in codebases, and because it is what a search engine or a model trained on the 2.3 documentation will still hand you.
What it leaves out is the part nobody notices until it matters, and almost all of it fails silently:
$jshas to come from somewhere.PublishedAssets::url()takes an absolute filesystem path, so the package needs a class or a view composer holding__DIR__.'/../dist/js/blog.js'and handing it to the view — the boilerplatehasAssets(entries: [...])exists to remove.url()returns?string. Wherepublic/cannot be written and nothing was published before, this renderssrc="". A browser resolves that against the current page and fetches the HTML as a script. Nothing throws, nothing 404s, and the page is broken. The directives emit no tag at all in that situation.- No
type="module". A Vite bundle has top-levelimport, which is a syntax error in a classic script. - No
data-navigate-track="reload". The?id=<mtime>is then a query string nobody reads: Livewire has no reason to full-page-reload awire:navigatevisit, so an upgrade lands as new markup running against the JavaScript the browser already cached — the exact failure the mirror's cache busting was for. - No CSP nonce. Under
Vite::useCspNonce()a strict policy blocks the tag. - No Vite resolution. An application that compiles this entry into its own build still gets the
shipped
dist/copy here, while every directive on the same page serves the built one. - The path is unchecked. A typo resolves to
null, which is the second point again. defer, and stylesheets before scripts, are then also yours to remember.
The declaration knows every one of these, which is why it is the declaration that renders:
@packageScripts('blog')
For markup the toolkit does not render — an <img>, a <link rel="preload">, an inline
import() — @packageAssetUrl('blog', 'js/blog.js') gives the bare URL with the same resolution
behind it.
Scripts are modules
.js renders as type="module", which is what a Vite build produces. A package shipping an
IIFE or UMD bundle must say so — a module is deferred and its top-level declarations never reach
window, so a bundle that expects to export a global would silently stop working:
use NyonCode\LaravelPackageToolkit\Support\Asset; $packager->hasAssets(entries: [ 'css/blog.css', Asset::make('js/blog-legacy.js')->classic(), Asset::make('js/blog.js')->attributes(['data-turbo-track' => 'reload']), ]);
Asset is only needed for what a plain string cannot express. data-navigate-track="reload" is
on every tag by default — it is what makes the ?id= query string mean something to Livewire's
wire:navigate — and ->attributes(['data-navigate-track' => null]) removes it.
When the extension is not the answer
Whether an entry renders as a <link> or a <script> is inferred from its extension — css,
scss, sass, less, styl and pcss are stylesheets, everything else is a script. That
covers every build whose output extension matches its input. For the build that does not, say it:
$packager->hasAssets(entries: [ Asset::make('css/blog.blade.php')->asStylesheet(), Asset::make('js/blog.txt')->asScript(), ]);
Vite — in the application
Added in 2.4.0. The toolkit ships no Vite config, no build step and no manifest of its own, and that is the design rather than a gap. What it supports is the other direction: letting the consuming application's Vite build compile the package.
That is the case that actually needs help. An application on Tailwind has to run its own config over the package's Blade markup or half the package's classes are purged; an application shipping its own JavaScript would rather not load a second copy of a dependency it already bundles. Both need the package's sources inside the application's build — and then the package's own layout has to emit a hashed filename it can only learn from the application's manifest.
Declare the sources, and the entries they replace when the application does build them:
$packager ->name('Blog') ->hasAssets(entries: ['css/blog.css', 'js/blog.js']) ->hasViteAssets([ // Vite source, relative to the package root => the shipped file it stands in for 'resources/css/blog.css' => 'css/blog.css', 'resources/js/blog.js' => 'js/blog.js', ]);
Nothing else in the package changes. The template still says @packageAssets('blog').
Three ways to say it
The map above is the shorthand. All three forms below declare the same thing, and mixed forms in
one call are fine — reach past the shorthand only when an entry also needs classic(),
attributes() or an explicit kind:
$packager->hasViteAssets([ 'resources/css/blog.css' => 'css/blog.css', // source => shipped fallback 'resources/js/blog-legacy.js', // source only, no fallback Asset::vite('resources/js/blog.js')->fallback('js/blog.js') ->attributes(['data-turbo-track' => 'reload']), ]);
Declaring the same shipped file in both calls is not a mistake and not a duplicate. A later entry
for a file already declared replaces the earlier one, and renders once, in the position it was
first declared — which is exactly what makes the hasAssets(entries: …) list above and the
hasViteAssets() map naming the same files add up to two tags rather than four.
Replacing is about the tag count, not about starting over. The replacement inherits what the entry
it replaces said about the tag itself, so the shorthand does not have to repeat a classic() it has
no way to express:
$packager ->hasAssets(entries: [Asset::make('js/blog.js')->classic()]) ->hasViteAssets(['resources/js/blog.js' => 'js/blog.js']); // Built by the application → its hashed module.// Not built → the shipped IIFE, still classic, still deferred.
classic() is sticky: nothing declares "explicitly a module", so a module cannot be told apart from
the default, and the direction that keeps a working bundle working is the one that survives.
Attributes merge, with the newer declaration winning a collision, and an explicit asStylesheet()
or asScript() on the replacement stands — the replacement can only have said either deliberately.
Fixed in 2.4.1
Before 2.4.1 the replacement was a blank entry: the pairing above lost its classic() and served the
shipped IIFE as type="module", whose top-level declarations never reach window. It only showed on
the fallback path — an application that built the entry never saw it — which is what made it worth
naming here.
An application that wants in adds the package's sources to its own config — the path it writes there is exactly the declared source prefixed with where the package lives:
export default defineConfig({ plugins: [ laravel({ input: [ 'resources/js/app.js', 'vendor/acme/blog/resources/js/blog.js', 'vendor/acme/blog/resources/css/blog.css', ], }), ],})
@source "../../vendor/acme/blog/resources/views";
From then on @packageAssets('blog') resolves through the application's manifest — hashed
filename, its preloads, its Tailwind pass — and is served hot alongside everything else while
npm run dev runs. An application that never touches its vite.config.js keeps getting the
shipped files from the mirror, exactly as before.
Resolution is per entry
Each entry is looked up on its own, so the two modes mix — which is the common case, not an edge one: an application typically wants the CSS in its build (Tailwind) and is happy with the shipped JavaScript.
npm run devis running → the dev server serves it.- The application's manifest has the key → the application's built file.
- Otherwise → the shipped file, through the mirror.
A miss falls back rather than throwing. @vite throws on an unknown entry, which is right in an
application's own layout and wrong inside a package's: the package author cannot fix the
application's Vite config, and a 500 on every page is a poor way to say "this could have been
faster".
When the prefix cannot be derived
vendor/acme/blog is read off the package's own location, which is right for anything installed
by Composer. A package symlinked in from a path repository sits outside the application root, and
there is nothing to derive — say it explicitly:
$packager->hasViteAssets([...], base: 'vendor/acme/blog');
Getting this wrong is quiet by design: the manifest lookup misses and the shipped file is served, which looks exactly like an application that chose not to build the package.
Knowing which one you got
Falling back is silent, and the most common mistake on the application's side is silent for the same reason: an input listed under a path one segment off from the manifest key leaves every page working, served from the shipped file, with nothing saying the build you configured is not being used.
A package with hasAbout() reports it where you already look. The row appears in console only,
and only for a package that declared Vite sources — there is nothing to disambiguate otherwise:
Blog .......................................................................... Version ................................................................ 2.1.0 Assets .......... css/blog.css: application build, js/blog.js: shipped
Or ask directly — dev server, application build, shipped,
fallback, not published, unresolved:
use NyonCode\LaravelPackageToolkit\Support\PackageAssets; app(PackageAssets::class)->resolution('blog');// ['css/blog.css' => 'application build', 'js/blog.js' => 'shipped']
Nothing is written to find out. The mirror publishes on demand, so an entry it has not reached yet
reports shipped on the strength of the copy being one it could still make — which is asked, not
assumed. Where public/ cannot be written that copy never appears, the entry is served by the
fallback or by nothing at all, and a flat shipped would be
this report's own version of the silence it exists to break — on the deployments least equipped to
notice.
Content Security Policy
Vite::useCspNonce() puts a nonce on every tag Laravel generates, and the toolkit carries the same
one onto the tags it renders itself — otherwise a strict policy would load the entry the
application built and block the one falling back to the shipped file. A nonce declared on an entry
wins over the application's.
Entries with no shipped copy
A package that ships no built assets at all can declare sources alone. Then there is no fallback: the entry renders when the application built it and renders nothing when it did not.
$packager->hasViteAssets(['resources/js/blog.js']);
A custom directory
$packager->hasAssets('assets'); // ../assets$packager->hasAssets('public'); // ../public$packager->hasAssets('resources/dist'); // ../resources/dist
The directory is resolved relative to your provider's directory, and must exist — a missing one
throws DirectoryNotFoundException at registration.
Building what you ship
The toolkit builds nothing. The dist/ you ship is yours to produce, and the setup below is what
pairs with hasViteAssets(): a library build whose entries are the same source files an
application would list in its own config, so the two modes stay in step by construction.
import { defineConfig } from 'vite' export default defineConfig({ build: { outDir: 'dist', emptyOutDir: true, lib: { // The same file the application lists as its input, when it builds the package itself. entry: { blog: 'resources/js/blog.js' }, formats: ['es'], }, rollupOptions: { output: { entryFileNames: 'js/[name].js', assetFileNames: 'css/[name][extname]', }, }, },})
{ "scripts": { "build": "vite build" }}
Commit dist/. A package consumer runs composer require, not npm run build.
Hashed filenames are for the application's build, not yours
The mirror cache-busts with ?id=<mtime>, and a hashed name in dist/ would defeat the mtime
comparison its incremental sync depends on while leaving every old hash behind in public/. Keep
the shipped names stable — hashing is the application's build's job, and it does it for you the
moment it takes over an entry.
Introspection
$packager->isAssetable(); // bool$packager->assetDirectory(); // absolute path$packager->mirrorsAssets(); // bool — false after hasAssets(mirror: false)$packager->hasAssetEntries(); // bool — anything declared for a template to render$packager->assetEntries(); // Support\Asset[] in declaration order$packager->viteBase(); // ?string — only when given to hasViteAssets()$packager->assetFallback(); // ?Closure — as given to hasAssetFallback()
Testing
use NyonCode\LaravelPackageToolkit\Support\PublishedAssets; test('resolving one asset mirrors the whole directory', function () { $shipped = __DIR__.'/../dist/css/blog.css'; $url = app(PublishedAssets::class)->url('blog', $shipped); expect(public_path('vendor/blog/css/blog.css'))->toBeFile() ->and(public_path('vendor/blog/js/blog.js'))->toBeFile() ->and($url)->toBe( asset('vendor/blog/css/blog.css') .'?id='.filemtime(public_path('vendor/blog/css/blog.css')) );}); test('a published copy newer than the shipped one is left alone', function () { $shipped = __DIR__.'/../dist/css/blog.css'; $published = public_path('vendor/blog/css/blog.css'); File::ensureDirectoryExists(dirname($published)); File::put($published, '/* published by hand */'); touch($published, filemtime($shipped) + 10); app(PublishedAssets::class)->url('blog', $shipped); expect(File::get($published))->toBe('/* published by hand */');});
public/ is shared between tests in a way the container is not, so clean up between cases:
beforeEach(fn () => File::deleteDirectory(public_path('vendor/blog')));
Testing the application-built path
Write the manifest the application's build would have written, and render. Laravel memoises parsed manifests in a static that outlives an application a test rebuilds, so forget it between cases or the previous test's manifest answers for the next one:
use Illuminate\Foundation\Vite; function writeApplicationManifest(array $manifest): void{ File::ensureDirectoryExists(public_path('build')); File::put(public_path('build/manifest.json'), json_encode($manifest)); (new ReflectionClass(Vite::class))->getProperty('manifests')->setValue(null, []);} test('an entry the application built is served from its manifest', function () { writeApplicationManifest([ 'vendor/acme/blog/resources/js/blog.js' => [ 'file' => 'assets/blog-a1b2c3.js', 'src' => 'vendor/acme/blog/resources/js/blog.js', 'isEntry' => true, ], ]); expect(Blade::render('@packageScripts("blog")')) ->toContain('/build/assets/blog-a1b2c3.js') ->not->toContain('vendor/blog/js/blog.js');});
Writing public/hot covers the dev-server case; deleting both files covers the fallback.