Cómo versionar automáticamente tus archivos JS en Laravel (sin compilar nada)

Si cambias un archivo JavaScript, lo subes a producción y tus usuarios siguen viendo el comportamiento anterior, no estás viendo un error de tu código: estás viendo la caché del navegador.

Este artículo explica por qué pasa y cómo resolverlo en una línea de Blade, sin agregar procesos de compilación ni dependencias nuevas al proyecto.

Por qué el navegador se queda con la copia vieja

Cuando el navegador descarga https://tusitio.com/js/cart.js, guarda una copia local. La próxima vez que necesite ese archivo, no tiene ninguna razón para volver a pedirlo: la dirección es la misma, así que asume que el contenido también.

Ese comportamiento es deseable. Es lo que hace que tu sitio cargue rápido en la segunda visita. El problema es que el navegador no tiene forma de saber que tú cambiaste el archivo.

La solución conocida es cambiar la dirección cuando cambia el contenido, agregando un parámetro de versión:

<script src="/js/cart.js?v=1.0.0"></script>

Para el navegador, cart.js?v=1.0.0 y cart.js?v=1.0.1 son dos direcciones distintas. Al cambiar el número, descarga de nuevo.

El detalle que hace que esto falle

Escrito así, el número se sube a mano. Y ahí está el punto débil: para que funcione, quien modifica el archivo tiene que acordarse de abrir otro archivo distinto, encontrar la línea correcta y cambiar un dígito, justo en el momento en que su cabeza está en el bug que acaba de arreglar.

Es fácil que se pase por alto, y cuando se pasa, el síntoma aparece mucho después y en la computadora de otra persona. Nadie relaciona una cosa con la otra.

En un proyecto que revisamos hace poco encontramos los tres estados conviviendo: un archivo con ?v=1.0.0, otro con ?v=1.0.2 que llevaba varios cambios sin actualizar, y un tercero igual de crítico sin ningún parámetro.

Vale la pena la distinción: el mecanismo estaba bien planteado. Lo que fallaba era que dependía de un paso manual.

La solución: que el número lo calcule el sistema

PHP puede leer la fecha de última modificación de un archivo con filemtime(). Ese valor cambia solo cuando el archivo cambia. Es exactamente el número de versión que necesitamos.

<script src="{{ asset('js/cart.js') }}?v={{ filemtime(public_path('js/cart.js')) }}"></script>

Al modificar cart.js, su fecha de modificación cambia, el parámetro ?v= cambia y el navegador descarga la copia nueva. Si no lo modificas, el número se mantiene y el navegador sigue usando lo que ya tiene en caché, que es justo lo que queremos.

Sin build. Sin Node. Sin recordar nada.

Agrega la protección contra archivo faltante

filemtime() emite un warning y devuelve false si el archivo no existe. En producción el archivo siempre está, pero si tienes APP_DEBUG=true en local y renombras el archivo un momento, ese warning puede escalar a excepción y tumbarte la página con un mensaje que no apunta al verdadero problema.

Se resuelve así:

<script src="{{ asset('js/cart.js') }}?v={{ @filemtime(public_path('js/cart.js')) ?: '' }}"></script>

La arroba silencia el warning y el ?: deja el parámetro vacío en lugar de imprimir false. El script sigue cargando.

Si tienes varios archivos, usa un helper

Repetir la ruta dos veces por archivo se vuelve incómodo rápido. Una clase pequeña lo resuelve:

<?php

namespace App\View;

class Asset
{
    /**
     * URL del asset con parámetro de versión derivado de su fecha de modificación.
     */
    public static function versioned(string $path): string
    {
        $version = @filemtime(public_path($path)) ?: '';

        return asset($path) . ($version ? '?v=' . $version : '');
    }
}

Y en la plantilla:

<script src="{{ \App\View\Asset::versioned('js/cart.js') }}"></script>
<script src="{{ \App\View\Asset::versioned('js/custom.js') }}"></script>
<link rel="stylesheet" href="{{ \App\View\Asset::versioned('css/custom.css') }}">

Colocarla bajo app/ te ahorra tocar composer.json, porque PSR-4 ya cubre ese directorio. Si prefieres una función global en app/helpers.php, funciona igual, pero recuerda que entonces necesitas registrarla en el autoload y ejecutar composer dump-autoload en cada despliegue.

Cómo comprobar que funciona

No te quedes con que la URL se ve distinta. Verifica que el navegador realmente descargó el archivo.

  1. Abre la página y las herramientas de desarrollo, en la pestaña Red.
  2. Localiza tu archivo JS y anota el valor de ?v=.
  3. Modifica el archivo en el servidor. Si solo quieres probar el mecanismo sin cambiar código, touch public/js/cart.js basta para actualizar su fecha.
  4. Recarga con F5, sin vaciar caché a mano.

Deberías ver un ?v= distinto. Pero lo importante es la columna Tamaño: si dice un valor en KB, el archivo se descargó; si dice “(memory cache)” o “(disk cache)”, se sirvió de la caché y algo no funcionó.

Desde la consola también puedes verificarlo:

performance.getEntriesByType('resource')
  .filter(r => r.name.includes('cart.js'))
  .map(r => ({ url: r.name, bytes: r.transferSize, deCache: r.transferSize === 0 }));

Si transferSize es mayor que cero, el archivo vino de la red.

Comprueba también que al modificar un archivo solo cambie el parámetro de ese archivo. Cada uno lleva su propio control, y eso evita descargas innecesarias de los demás.

Un detalle sobre tu despliegue

git checkout y git pull no preservan la fecha de modificación de los archivos. Si despliegas así, el parámetro de versión va a cambiar en cada despliegue, incluso para archivos que no tocaste.

No es un problema: el usuario descarga unos KB de más una vez por despliegue y nunca recibe una copia desactualizada, que es lo que importa. Si quieres que el parámetro cambie estrictamente cuando cambia el contenido, sustituye filemtime() por un hash:

$version = substr(md5_file(public_path($path)), 0, 12);

El costo es leer el archivo completo en cada petición en lugar de consultar solo sus metadatos. Para archivos chicos es despreciable; si te preocupa, guárdalo en caché.

¿Y cuándo conviene compilar con Vite o Mix?

Si tu proyecto ya tiene un proceso de compilación funcionando, úsalo: @vite y mix() resuelven esto con un hash del contenido y además te dan minificación y empaquetado.

Esta solución está pensada para un escenario distinto y bastante común: proyectos donde el JavaScript vive en public/, se edita directamente y no hay pipeline montado. Ahí, incorporar los archivos a un proceso de compilación es una migración con su propio trabajo y sus propios riesgos, mientras que el problema de la caché es urgente hoy.

Un punto a considerar antes de decidirte por compilar: mix() lanza una excepción si el manifiesto está desincronizado con los archivos. Si esa llamada vive en una plantilla compartida, un despliegue sin compilar no te rompe una sección, te deja el sitio entero en blanco. Vale la pena tenerlo previsto en tu proceso de despliegue.

Las dos soluciones pueden convivir. Puedes resolver la caché hoy con esta línea y migrar a un pipeline completo cuando el proyecto lo pida, sin prisa y con todos los assets a la vez.

Resumen

  • Agrega ?v={{ @filemtime(public_path($ruta)) ?: '' }} a tus etiquetas de script y estilo.
  • Si son varios archivos, encapsúlalo en un helper bajo app/.
  • Verifica en la pestaña Red que el archivo se descargue, no solo que la URL cambie.
  • Revisa si tienes otros mecanismos que dependan de un paso manual: son los que fallan en silencio.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *