¿Qué vamos a construir?
Una API REST para gestionar clientes con los campos que se usan en proyectos colombianos reales: nombre, NIT (con dígito de verificación), email, teléfono, dirección y estado activo.
Al final de este tutorial tendrás estos endpoints funcionando y probados con Insomnia:
GET /api/clientes → lista todos los clientes
POST /api/clientes → crea un cliente
GET /api/clientes/{id} → obtiene un cliente por ID
PUT /api/clientes/{id} → actualiza un cliente
DELETE /api/clientes/{id} → elimina un cliente
POST /api/clientes/bulk → inserción masiva
GET /api/user → usuario autenticado (protegido con Sanctum)
Crear el proyecto e instalar Sanctum
Creamos un proyecto Laravel limpio y habilitamos el soporte para APIs con Sanctum:
composer create-project laravel/laravel api-practica
cd api-practica
php artisan install:api
El comando install:api hace tres cosas automáticamente: publica la migración de personal_access_tokens, agrega la configuración de Sanctum y crea el archivo routes/api.php si no existe.
http://api-practica.test. Si usás php artisan serve queda en http://localhost:8000. Todos los endpoints de la API van bajo /api/.
La migración
Creamos la migración para la tabla de clientes:
php artisan make:migration create_clientes_table
El campo clave es nit con restricción unique() — en Colombia no pueden existir dos empresas con el mismo NIT ante la DIAN:
Schema::create('clientes', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('nit')->unique();
$table->string('email')->nullable();
$table->string('phone')->nullable();
$table->string('address')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
});
php artisan migrate
XXXXXXXXX-X (nueve dígitos más dígito de verificación). Lo guardamos como string para preservar ese formato exacto. Para validación avanzada del algoritmo DIAN podés usar el paquete jamesmosq/laravel-fiscal-colombia.
El Modelo
php artisan make:model Cliente
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Cliente extends Model
{
protected $fillable = [
'name',
'nit',
'email',
'phone',
'address',
'active',
];
}
El Controlador
Generamos un controlador de API — usa --api en vez de --resource para omitir los métodos create y edit que no tienen sentido en una API REST:
php artisan make:controller ClienteController --api
Completamos cada método en app/Http/Controllers/ClienteController.php:
<?php
namespace App\Http\Controllers;
use App\Models\Cliente;
use Illuminate\Http\Request;
class ClienteController extends Controller
{
public function index()
{
return response()->json(Cliente::all());
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string',
'nit' => 'required|string|unique:clientes,nit',
'email' => 'nullable|email',
'phone' => 'nullable|string',
'address' => 'nullable|string',
]);
$cliente = Cliente::create($request->all());
return response()->json($cliente, 201);
}
public function show(Cliente $cliente)
{
return response()->json($cliente);
}
public function update(Request $request, Cliente $cliente)
{
$request->validate([
'name' => 'sometimes|string',
'nit' => 'sometimes|string|unique:clientes,nit,' . $cliente->id,
'email' => 'nullable|email',
'phone' => 'nullable|string',
'address' => 'nullable|string',
]);
$cliente->update($request->all());
return response()->json($cliente);
}
public function destroy(Cliente $cliente)
{
$cliente->delete();
return response()->json(['message' => 'Cliente eliminado correctamente']);
}
public function bulkStore(Request $request)
{
$request->validate([
'*.name' => 'required|string',
'*.nit' => 'required|string|distinct',
]);
$clientes = collect($request->all())->map(function ($item) {
return array_merge($item, [
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
})->toArray();
Cliente::insert($clientes);
return response()->json(['message' => 'Clientes creados correctamente'], 201);
}
}
update() el NIT usa la regla unique:clientes,nit,{id} para ignorar el propio registro al validar unicidad — sin esto, actualizar cualquier otro campo fallaría por el NIT existente del mismo cliente.
Las rutas
En routes/api.php registramos el resource y la ruta personalizada para inserción masiva:
<?php
use App\Http\Controllers\ClienteController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
// Ruta protegida con Sanctum
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
// Rutas de clientes
Route::post('clientes/bulk', [ClienteController::class, 'bulkStore']);
Route::apiResource('clientes', ClienteController::class);
clientes/bulk debe ir antes de apiResource. Si va después, Laravel interpreta "bulk" como el parámetro {cliente} de la ruta show y nunca llega al método correcto.
Verificá las rutas generadas:
php artisan route:list --path=api
GET api/clientes clientes.index
POST api/clientes clientes.store
GET api/clientes/{cliente} clientes.show
PUT api/clientes/{cliente} clientes.update
DELETE api/clientes/{cliente} clientes.destroy
POST api/clientes/bulk (bulk)
Autenticación con Sanctum
Sanctum ya está instalado. Para proteger las rutas de clientes con autenticación por token, envolvés las rutas en el middleware auth:sanctum:
Route::middleware('auth:sanctum')->group(function () {
Route::post('clientes/bulk', [ClienteController::class, 'bulkStore']);
Route::apiResource('clientes', ClienteController::class);
});
Para generar un token desde Tinker y probarlo en Insomnia:
php artisan tinker
$user = App\Models\User::first();
$token = $user->createToken('insomnia-test')->plainTextToken;
echo $token;
En Insomnia agregás el header:
Authorization: Bearer 1|tu_token_aqui
Probando con Insomnia
Con el servidor corriendo, estos son los resultados reales de cada endpoint:
GET /api/clientes — lista todos los clientes:
[
{
"id": 1,
"name": "Empresa Laravel SAS",
"nit": "900111333-5",
"email": "contacto@laraveltest.com",
"phone": "3001234567",
"address": "Calle 10 # 20-30, Medellín",
"active": 1,
"created_at": "2026-05-09T23:32:54.000000Z",
"updated_at": "2026-05-09T23:32:54.000000Z"
},
{
"id": 2,
"name": "Ferretería El Tornillo SAS",
"nit": "900111222-1",
"email": "contacto@tornillo.com",
"phone": "3101234567",
"address": "Calle 50 # 10-20, Medellín",
"active": 1,
"created_at": "2026-05-09T23:41:31.000000Z",
"updated_at": "2026-05-09T23:41:31.000000Z"
}
]
POST /api/clientes — crear un cliente (body JSON):
{
"name": "Constructora Palma Real SAS",
"nit": "901234567-3",
"email": "cxp@palmareal.com.co",
"phone": "6044321098",
"address": "Cl 10 # 43E-31, Medellín"
}
Respuesta 201 Created:
{
"id": 7,
"name": "Constructora Palma Real SAS",
"nit": "901234567-3",
"email": "cxp@palmareal.com.co",
"phone": "6044321098",
"address": "Cl 10 # 43E-31, Medellín",
"active": 1,
"created_at": "2026-05-09T23:45:37.000000Z",
"updated_at": "2026-05-09T23:45:37.000000Z"
}
POST /api/clientes/bulk — inserción masiva (array de objetos):
[
{
"name": "Distribuidora Andina Ltda",
"nit": "800543210-2",
"email": "ventas@andina.com",
"phone": "3157654321",
"address": "Cra 30 # 8-61, Bogotá"
},
{
"name": "Inversiones Pacífico SA",
"nit": "890765432-4",
"email": "tesoreria@invpacifico.com",
"phone": "6022109876",
"address": "Av. 6N # 23A-26, Cali"
},
{
"name": "Logística Express Colombia SAS",
"nit": "900543210-5",
"email": "admin@logexpress.co",
"phone": "3157654322",
"address": "Cra 30 # 8-61, Bogotá"
}
]
Respuesta 201 Created:
{ "message": "Clientes creados correctamente" }
DELETE /api/clientes/{id}:
{ "message": "Cliente eliminado correctamente" }