La diferencia entre PUT y PATCH está en qué pasa con lo que no envías: PUT reemplaza el recurso entero por el que mandas en la petición, y PATCH solo modifica los campos que incluyes y deja el resto como estaba. Si con PUT te dejas un campo, ese campo se pierde o vuelve a su valor por defecto, y con PATCH se queda tal cual.
Esta tabla resume las diferencias entre los 2 métodos, y debajo tienes cada fila explicada con ejemplos:
Característica | PUT | PATCH |
|---|---|---|
Qué envías | El recurso completo | Solo los campos que cambian |
Campos que no envías | Se pierden o vuelven a su valor por defecto | Se quedan como estaban |
Idempotente | Sí, por definición | No por definición, aunque puede comportarse como tal |
Crear el recurso | Sí, si el cliente conoce la URI (201 Created) | Solo si el servidor lo permite, y es poco habitual |
En Laravel | Método | El mismo método |
En este artículo vamos a ver los 2 métodos con peticiones reales, qué significa que PUT sea idempotente y PATCH no tenga por qué serlo, en qué se diferencia PUT de POST y cómo se implementan los 2 en Laravel 13, con código probado y las respuestas que devuelve la API.
¿Qué es PUT?
PUT le dice al servidor que el estado del recurso que vive en una URI tiene que ser exactamente el que le envías. Así lo define el RFC 9110, el estándar de la semántica de HTTP: PUT crea o reemplaza el estado del recurso con la representación que va en el cuerpo de la petición, y una respuesta correcta implica que un GET posterior a esa misma URI devolverá algo equivalente a lo que enviaste.
Vamos a verlo con una tarea, que es el recurso que usaremos en todo el artículo. Partimos de este estado:
{"id": 1, "title": "Preparar la release", "description": "Changelog y tag", "priority": 2, "completed": false}Si queremos marcarla como completada con PUT, tenemos que enviar la tarea entera, también los campos que no cambian:
PUT /api/tasks/1 HTTP/1.1
Content-Type: application/json
{"title": "Preparar la release", "description": "Changelog y tag", "priority": 2, "completed": true}¿Y si te dejas description porque no la ibas a tocar? Con PUT, lo que no envías no forma parte del nuevo estado, así que el servidor tiene 2 opciones correctas: dejar ese campo vacío o con su valor por defecto, o rechazar la petición porque la representación está incompleta. Lo que no debería hacer es conservar el valor anterior sin decir nada, porque entonces tu PUT se está comportando como un PATCH y quien consume la API ya no sabe qué esperar.
Esto tiene una consecuencia práctica. Como PUT envía el recurso completo, el cliente normalmente hace primero un GET, cambia lo que necesita y devuelve el resto tal cual lo recibió. Si otra persona ha cambiado el título de la tarea entre tu GET y tu PUT, tu petición vuelve a poner el título antiguo y su cambio se pierde, aunque tú solo quisieras tocar la prioridad.
¿Qué es PATCH?
PATCH llegó más tarde, con el RFC 5789, justo para cubrir lo que PUT no hace: las modificaciones parciales. El propio RFC explica la diferencia así: con PUT, el cuerpo es una versión modificada del recurso que sustituye a la que tiene guardada el servidor, y con PATCH, el cuerpo es un conjunto de instrucciones que describen cómo modificar el recurso para obtener una versión nueva.
Para marcar la misma tarea como completada basta con esto:
PATCH /api/tasks/1 HTTP/1.1
Content-Type: application/json
{"completed": true}El título, la descripción y la prioridad se quedan como estaban. Fíjate en que el cuerpo es mucho más pequeño, pero lo más útil es que el cliente no necesita conocer el resto del recurso para cambiar un campo, y si otro cliente cambia el título a la vez, ninguno pisa el cambio del otro.
¿Qué formato tiene ese cuerpo? El RFC 5789 no lo fija. En la práctica, y en todo lo que vamos a hacer en Laravel, suele ser un JSON con los campos que cambian, que es la idea de JSON Merge Patch (RFC 7396, application/merge-patch+json): lo que envías se sobrescribe, lo que no envías se queda igual y un null elimina el campo. Existe otro formato, JSON Patch (RFC 6902, application/json-patch+json), que en lugar de campos envía una lista de operaciones que se aplican en orden:
[
{"op": "replace", "path": "/completed", "value": true},
{"op": "add", "path": "/tags/-", "value": "release"}
]JSON Patch es más potente, porque permite añadir o quitar elementos concretos de un array, pero también es más laborioso de implementar, y para una API CRUD suele bastar con el primero.
¿Por qué PUT es idempotente y PATCH no tiene por qué serlo?
Un método es idempotente cuando repetir la misma petición varias veces tiene el mismo efecto en el servidor que hacerla una sola vez. El RFC 9110 considera idempotentes PUT, DELETE y los métodos seguros (GET, HEAD, OPTIONS y TRACE), y deja fuera POST. De PATCH, el RFC 5789 dice que no es seguro ni idempotente.
¿Y esto para qué sirve en la práctica? Para los reintentos. Si un PUT se corta por un timeout y no sabes si ha llegado, puedes repetirlo sin miedo, porque si ya se había aplicado, el segundo deja la tarea exactamente igual. Con PATCH depende de lo que envíes. {"completed": true} deja el mismo resultado si lo mandas 1 vez o 10, así que se comporta como idempotente, pero la operación add de JSON Patch sobre /tags/- añade la etiqueta al final del array cada vez que se ejecuta, y lo mismo pasa con cualquier PATCH que diga "suma 5 al stock": repetirlo cambia el resultado.
Para esos casos, el RFC 5789 recomienda peticiones condicionales: el cliente envía en la cabecera If-Match el ETag del recurso que tenía, y si el recurso ha cambiado desde entonces, la petición falla con un 412 Precondition Failed en lugar de aplicarse 2 veces. Esto también te protege del problema de los cambios pisados que hemos visto con PUT.
POST vs PUT: ¿en qué se diferencian?
Es la otra duda que aparece siempre al lado de esta, y la diferencia está en quién decide la URI del recurso. Con POST envías los datos a una colección, /api/tasks, y es el servidor el que decide qué hacer con ellos: crea la tarea, le asigna un id y responde con un 201 Created. Con PUT eres tú, el cliente, quien dice en qué URI vive el recurso, /api/tasks/1, y el servidor lo crea o lo reemplaza.
La consecuencia es, otra vez, la idempotencia. Si repites el mismo POST, tienes 2 tareas iguales con ids distintos, que es justo lo que vamos a ver en Laravel en un momento, y si repites el mismo PUT, sigues teniendo una sola tarea con el mismo estado. Por eso POST es lo habitual para crear cuando el id lo genera la base de datos, y crear con PUT solo tiene sentido cuando el cliente conoce el identificador de antemano, por ejemplo un UUID que genera el propio cliente o un código de producto que ya existe en otro sistema.
PUT y PATCH en Laravel
Laravel no distingue entre los 2. Route::resource y Route::apiResource registran PUT y PATCH sobre la misma URI, y los 2 llegan al método update del controlador, así que la diferencia de comportamiento la tienes que implementar tú. Vamos a hacerlo con la API de tareas de los ejemplos.
Todo lo que sigue está probado con Laravel 13 y PHP 8.5 (Laravel 13 funciona desde PHP 8.3). En una aplicación nueva, las rutas de API no vienen activadas: las añade php artisan install:api, que crea routes/api.php e instala Sanctum. Si no lo has hecho nunca, lo tienes paso a paso en cómo instalar la API en Laravel. Después, vamos a generar el modelo con su migración, el controlador de API y el Form Request:
php artisan install:api
php artisan make:model Task -m
php artisan make:controller TaskController --api --model=Task
php artisan make:request TaskRequestLa migración que se crea en database/migrations/, terminada en _create_tasks_table.php, define los 4 campos de la tarea en su método up():
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->unsignedTinyInteger('priority')->default(3);
$table->boolean('completed')->default(false);
$table->timestamps();
});El modelo, app/Models/Task.php, declara los campos asignables, oculta las fechas para que las respuestas se lean mejor en este ejemplo y convierte priority y completed para que salgan en el JSON como número y como booleano:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Task extends Model
{
protected $fillable = ['title', 'description', 'priority', 'completed'];
protected $hidden = ['created_at', 'updated_at'];
protected function casts(): array
{
return [
'priority' => 'integer',
'completed' => 'boolean',
];
}
}Lo interesante está en la validación. Vamos a usar un único Form Request, app/Http/Requests/TaskRequest.php, para crear con POST, reemplazar con PUT y modificar con PATCH:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class TaskRequest extends FormRequest
{
public function rules(): array
{
$rules = [
'title' => ['required', 'string', 'max:255'],
'description' => ['present', 'nullable', 'string'],
'priority' => ['required', 'integer', 'between:1,5'],
'completed' => ['required', 'boolean'],
];
if ($this->isMethod('PATCH')) {
return array_map(fn (array $fieldRules) => ['sometimes', ...$fieldRules], $rules);
}
return $rules;
}
}Fíjate en 2 detalles. El primero es present en description: el campo admite null, pero tiene que venir en la petición, así que un PUT que se olvida la descripción recibe un 422 en lugar de dejarla vacía sin avisar. Es la segunda de las 2 opciones que veíamos antes, rechazar la representación incompleta, y es la que usamos aquí porque así el cliente que se equivoca se entera en la primera petición. El segundo es el PATCH: ponemos sometimes delante de las reglas de cada campo, y sometimes hace que un campo se valide solo si viene en la petición. Si viene, se le aplican las mismas reglas que en PUT, así que un PATCH con {"priority": 9} sigue devolviendo un 422.
Tampoco hay método authorize(). Si no existe, el Form Request no comprueba permisos, y en una API real esa comprobación iría en una policy. El controlador, app/Http/Controllers/TaskController.php, queda así de corto:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\TaskRequest;
use App\Models\Task;
class TaskController extends Controller
{
public function store(TaskRequest $request): Task
{
return Task::create($request->validated());
}
public function show(Task $task): Task
{
return $task;
}
public function update(TaskRequest $request, Task $task): Task
{
$task->update($request->validated());
return $task;
}
}El método update no sabe si la petición es PUT o PATCH, y no le hace falta. validated() devuelve solo los campos que han pasado la validación, así que en un PATCH contiene únicamente lo que ha enviado el cliente y update() solo modifica esas columnas, y en un PUT, como el Form Request exige todos los campos, trae la tarea completa. Devolvemos el modelo tal cual: Laravel lo convierte en JSON y, si se acaba de crear, responde con un 201 Created. En un proyecto real lo normal es pasarlo por un API Resource, y si dudas entre uno o varios, lo tienes en API Resources en Laravel: ¿usar un único resource o separar por plataformas?.
Solo queda la ruta, en routes/api.php, debajo de la ruta /user que deja install:api. Limitamos el recurso a las 3 acciones que hemos escrito:
use App\Http\Controllers\TaskController;
Route::apiResource('tasks', TaskController::class)->only(['store', 'show', 'update']);Con route:list vamos a comprobar que PUT y PATCH comparten URI y método del controlador:
php artisan route:list --path=tasksLa salida lo deja claro, con PUT|PATCH en la misma línea:
POST api/tasks ............... tasks.store › TaskController@store
GET|HEAD api/tasks/{task} .......... tasks.show › TaskController@show
PUT|PATCH api/tasks/{task} ...... tasks.update › TaskController@update
Showing [3] routesProbar PUT y PATCH con curl
El movimiento se demuestra andando, así que vamos a lanzar las peticiones con curl contra http://localhost, que es donde responde la aplicación con Laravel Sail. Todas llevan Accept: application/json, y -w añade al final el código de estado HTTP para que lo veas debajo del JSON. Primero creamos la tarea con POST:
curl -s -w '\n%{http_code}\n' -X POST http://localhost/api/tasks \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"title": "Preparar la release", "description": "Changelog y tag", "priority": 2, "completed": false}'La respuesta trae la tarea con su id y un 201:
{"title":"Preparar la release","description":"Changelog y tag","priority":2,"completed":false,"id":1}
201Si repites exactamente la misma petición, obtienes otro 201 con "id":2: 2 tareas iguales, que es lo que decíamos de POST. Ahora vamos a marcar la tarea 1 como completada con PATCH, enviando solo ese campo:
curl -s -w '\n%{http_code}\n' -X PATCH http://localhost/api/tasks/1 \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"completed": true}'Solo ha cambiado completed, y el resto sigue como estaba:
{"id":1,"title":"Preparar la release","description":"Changelog y tag","priority":2,"completed":true}
200Vamos con PUT, y lo hacemos mal a propósito, dejándonos la descripción:
curl -s -w '\n%{http_code}\n' -X PUT http://localhost/api/tasks/1 \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"title": "Publicar la release", "priority": 1, "completed": true}'La regla present hace su trabajo y la tarea no se toca:
{"message":"The description field must be present.","errors":{"description":["The description field must be present."]}}
422Con la representación completa, en este caso con la descripción a null de forma explícita, el PUT sí se aplica:
curl -s -w '\n%{http_code}\n' -X PUT http://localhost/api/tasks/1 \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"title": "Publicar la release", "description": null, "priority": 1, "completed": true}'Y la tarea queda exactamente como la hemos enviado:
{"id":1,"title":"Publicar la release","description":null,"priority":1,"completed":true}
200Si lanzas este mismo PUT otra vez, la respuesta es idéntica, porque es idempotente. Y si lo lanzas contra una tarea que no existe, como /api/tasks/99, Laravel responde con un 404, porque el route model binding busca la tarea antes de llegar al controlador. Es decir, con apiResource tu PUT no crea recursos. Si quieres que lo haga, tendrás que registrar esa ruta sin route model binding, crear la tarea cuando no exista y devolver un 201 en ese caso.
Formularios y errores de validación
En una aplicación con vistas Blade la ruta y el controlador son los mismos, pero hay un detalle: los formularios HTML solo envían GET y POST. Para que Laravel trate la petición como PUT o PATCH, el formulario va por POST y lleva la directiva @method, que añade un campo oculto _method con el verbo:
<form action="{{ route('tasks.update', $task) }}" method="POST">
@csrf
@method('PATCH')
<input type="hidden" name="completed" value="1">
<button type="submit">Marcar como completada</button>
</form>Con ese _method, isMethod('PATCH') devuelve true en el Form Request y la validación parcial funciona igual que desde la API. Lo que cambia es la respuesta cuando la validación falla. Laravel no devuelve un 422 en cualquier caso: en una petición de formulario normal, redirige a la página anterior con los errores guardados en la sesión, y solo cuando la petición espera JSON responde con un 422 y los errores en el cuerpo. En una aplicación nueva de Laravel 13, además, bootstrap/app.php trae shouldRenderJsonWhen configurado para que todo lo que va por api/* responda en JSON, así que las rutas de la API devuelven el 422 aunque el cliente no envíe la cabecera Accept.
¿Cuándo usar PUT y cuándo PATCH?
Usa PATCH cuando el cliente cambia una parte del recurso: marcar una tarea como completada, activar o desactivar un usuario, cambiar la dirección de envío de un pedido o guardar una pantalla que solo edita algunos campos. Es lo más habitual en el día a día, porque muchas pantallas editan unos pocos campos de un recurso mucho más grande, y con PATCH no tienes que cargar ni reenviar el resto.
Usa PUT cuando el cliente tiene el recurso completo y quiere sustituirlo: un formulario de edición que envía todos los campos, la sincronización de una configuración entera que se guarda de una vez o la creación de un recurso en una URI que el cliente ya conoce.
Y si ofreces los 2, como hace Laravel por defecto con apiResource, que cada verbo haga lo que promete. Un PUT que acepta campos sueltos y conserva el resto es un PATCH con otro nombre, y un PATCH que borra lo que no recibe es un PUT disfrazado. Documenta qué hace cada uno y aplica el mismo criterio en todos los recursos.
Sobre los códigos de respuesta, el RFC 9110 recoge 200 OK o 204 No Content cuando PUT reemplaza un recurso, según devuelvas el recurso actualizado o una respuesta sin cuerpo, y 201 Created cuando lo crea. Para PATCH, el RFC 5789 usa 204 en su ejemplo y admite otros códigos de éxito, así que un 200 con la tarea actualizada, como el de Laravel, es perfectamente válido. Para los errores, 404 si el recurso no existe y 422 si los datos no pasan la validación.
Si trabajas con Laravel, quédate con el Form Request: present y sometimes son las 2 reglas que hacen que cada verbo se comporte como promete. Espero que te haya resultado útil.
Si quieres seguir con los métodos HTTP, te recomiendo HTTP QUERY, el nuevo método HTTP que envía datos en el body, y para el diseño de APIs, qué es HATEOAS y por qué tu API debería devolver las URLs.
Y para profundizar en APIs con Laravel, tienes el curso Desarrollo Avanzado de API REST en Laravel.