In Laravel 13, how do you serve a versioned image referenced only from a Blade template through Vite, and what changed in laravel-vite-plugin 3?
answer
- Blade files are not in the module graph
- plugin assets option with globs
- Vite::asset returns the hashed URL
- import.meta.glob was the old way
- Vite 8 prompted the change
basics
~20 sList the files in the laravel-vite-plugin assets option so the build emits them with hashed names into the manifest, then print Vite::asset('resources/images/logo.png') in Blade. Plugin 3 (Vite 8) replaced the old import.meta.glob trick with this option.
solid answer
~40 sVite only versions files it discovers from an entry's import graph, and a Blade template is not part of that graph. In laravel-vite-plugin 3, which the Laravel 13 skeleton ships alongside Vite 8, you declare them in the plugin config: `assets: ['resources/images/**']`. During `npm run build` the plugin emits each matching file, so it gets a hashed name and a manifest key equal to its source path. In Blade you then write `{{ Vite::asset('resources/images/logo.png') }}`, which returns the versioned URL (or the dev-server URL while `npm run dev` runs). Before plugin 3 the same result needed `import.meta.glob([...])` inside `app.js`; the upgrade guide tells you to move those globs into `assets`.
code
javascript · 11 linesimport { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
assets: ['resources/images/**'],
}),
],
});go deeper
Know that Blade-only images need the plugin's assets option and are printed with Vite::asset, not the plain asset() helper.
Explain why Blade is outside the module graph, how the build emits the files into the manifest, and what Vite::asset returns in each mode.
Catch the version trap when upgrading to Vite 8: move import.meta.glob calls into assets, and rebuild whenever the file set changes.
Set the team rule on which files are hashed through Vite and which stay in public/ with fixed URLs, based on cache lifetime and CDN needs.
## The problem Vite builds a **module graph**: starting from each entry in the plugin's `input`, it follows `import` statements and `url()` references in CSS, copies every file it reaches into `public/build/assets` with a **content hash** in its name, and records the mapping in `public/build/manifest.json`. A logo referenced only from a Blade view — `<img src="...">` — is invisible to that graph, because Blade templates are rendered by PHP and never imported by JavaScript. Without extra configuration the file is not copied, not hashed and not in the manifest. ## The Laravel 13 answer: the `assets` option laravel-vite-plugin 3 adds an `assets` option that takes one glob or an array of globs: ```javascript laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], assets: ['resources/images/**', 'resources/fonts/**'], }) ``` During a **build** (the option does nothing in dev), the plugin walks the globs and emits each regular file into the bundle. Vite hashes it like any other asset and writes a manifest entry keyed by the original path, e.g. `resources/images/logo.png`. The plugin also sets Vite's `assetsInlineLimit` to `0` unless you override it, so small images are written as files rather than inlined as base64 data URLs — which is what guarantees they have a manifest entry to look up. ## Printing the URL in Blade ```html @use('Illuminate\Support\Facades\Vite') <img src="{{ Vite::asset('resources/images/logo.png') }}" alt="Logo"> ``` `Vite::asset($path, $buildDirectory = null)` behaves by mode: - **Hot file present**: returns the dev-server URL plus the path, so the image is served live from the dev server. - **Build mode**: looks the path up in the manifest and returns `asset('build/assets/logo-<hash>.png')`, so `ASSET_URL` applies if you serve assets from a CDN. - **Not in the manifest**: throws `ViteException` ("Unable to locate file in Vite manifest"). That is the error you get if you add a file after the last build or forget the glob. The `Vite` facade is aliased globally, so templates can usually call it without the `@use` line; the explicit import is shown for clarity. ## What changed across plugin versions | | laravel-vite-plugin 2.x and earlier | laravel-vite-plugin 3.x (Vite 8, Laravel 13 skeleton) | |---|---|---| | How Blade-only files enter the build | `import.meta.glob(['../images/**'])` in `resources/js/app.js` | `assets: ['resources/images/**']` in `vite.config.js` | | Why | globbing pulled the files into the module graph | Laravel's docs say the option was introduced because of changes in Vite 8; the plugin now emits the files itself | | Blade side | `Vite::asset()` | unchanged: `Vite::asset()` | The plugin's upgrade guide for 2.x → 3.x shows exactly this migration: delete the `import.meta.glob` block from `app.js`, add the same globs to `assets`. ## Related tools - **`Vite::content($path)`** returns the file's raw contents from the build instead of a URL — for inlining CSS into a PDF or email template. It throws `ViteException` if the built file is missing on disk. - **Aliases via macros**: `Vite::macro('image', fn (string $asset) => $this->asset("resources/images/{$asset}"));` in a service provider's `boot()` lets templates write `Vite::image('logo.png')`. - **The `fonts` option** is separate: it is for font files Laravel should turn into `@font-face` CSS and preload links, not for arbitrary static files. - **Files placed directly in `public/`** are served as they are, without hashing; they suit `favicon.ico` or `robots.txt`, not assets you want to cache forever. ## Pitfalls - **Globs are evaluated at build time.** A file added after the last build has no manifest key, so `Vite::asset()` throws until the next build ships. - **Paths are source paths.** `Vite::asset('images/logo.png')` fails if the manifest key is `resources/images/logo.png`; the argument must match the key exactly. - **Nothing in `resources/` is web-served.** Writing `/resources/images/logo.png` into an `<img>` tag produces a 404 in every environment. - **The plain `asset()` helper does no versioning.** It prefixes a path under `public/` with the app or asset URL and nothing more, so it cannot find a hashed copy. ## Choosing between them 1. Referenced from JavaScript or CSS → just import it; Vite versions it automatically. 2. Referenced only from Blade and should be cache-busted → `assets` + `Vite::asset()`. 3. Must keep a fixed, well-known URL → put it in `public/`. 4. Needs its bytes inline → `Vite::content()`.
- You add a new image under resources/images and the page throws ViteException in production. Why, if the glob already covers it?The `assets` globs are evaluated at build time. The manifest on the server comes from the last `npm run build`, which ran before the file existed, so it has no key for it. Rebuilding and deploying the new manifest together with the code fixes it.
- When would you choose Vite::content() over Vite::asset()?When the consumer cannot fetch a URL — a PDF renderer or an HTML email that needs the CSS or script inlined. `Vite::content()` reads the built file from `public/build` and returns its text, so it only works after a build, while `Vite::asset()` returns a URL and also works against the dev server.
saying these in an interview costs you the question
- Any file under resources/ is copied into public/build automatically
- The global asset() helper returns a hashed Vite URL
- Vite::asset works for a Blade-only image without any plugin configuration
- Plugin 3 still expects import.meta.glob in app.js for Blade images
- Put images in public/ if you want them cache-busted