Overview
BeanQuick provides businesses with comprehensive product management capabilities including creating, reading, updating, and deleting products. Each product is associated with a category, includes pricing and stock information, and can have an image.
Product Model
Database Schema
Table: productos
Fillable Fields:
'empresa_id' , 'categoria_id' , 'nombre' , 'descripcion' , 'precio' , 'imagen' , 'stock'
Relationships:
empresa() - belongsTo Empresa
categoria() - belongsTo Categoria
carritos() - belongsToMany Carrito (pivot: carrito_productos)
pedidos() - belongsToMany Pedido (pivot: pedido_productos)
calificaciones() - hasMany Calificacion
Casts:
'precio' => 'float' ,
'stock' => 'integer'
Appended Attributes:
imagen_url - Full URL to product image (or placeholder if none)
Image URL Accessor
public function getImagenUrlAttribute ()
{
return $this -> imagen
? asset ( 'storage/' . $this -> imagen )
: asset ( 'images/placeholder-producto.png' );
}
CRUD Operations
List All Products (Business Dashboard)
Endpoint: GET /api/empresa/productos
Authentication: Required (empresa role)
Returns: All products belonging to the authenticated business
Response:
[
{
"id" : 1 ,
"empresa_id" : 5 ,
"categoria_id" : 2 ,
"nombre" : "Café Americano" ,
"descripcion" : "Café negro preparado con granos colombianos" ,
"precio" : 5000.0 ,
"stock" : 100 ,
"imagen" : "productos/abc123.jpg" ,
"imagen_url" : "http://localhost:8000/storage/productos/abc123.jpg" ,
"created_at" : "2026-03-05T10:30:00.000000Z" ,
"updated_at" : "2026-03-05T10:30:00.000000Z" ,
"categoria" : {
"id" : 2 ,
"nombre" : "Bebidas Calientes"
}
}
]
Controller Logic:
public function index () : JsonResponse
{
$empresa = Empresa :: where ( 'user_id' , Auth :: id ()) -> first ();
if ( ! $empresa ) {
return response () -> json ([ 'message' => 'No tienes una empresa vinculada.' ], 404 );
}
$productos = Producto :: where ( 'empresa_id' , $empresa -> id )
-> with ( 'categoria' )
-> get ();
return response () -> json ( $productos );
}
View Single Product
Endpoint: GET /api/empresa/productos/{id}
Authentication: Required (empresa role)
Authorization: Product must belong to the authenticated business
Response:
{
"id" : 1 ,
"empresa_id" : 5 ,
"categoria_id" : 2 ,
"nombre" : "Café Americano" ,
"descripcion" : "Café negro preparado con granos colombianos" ,
"precio" : 5000.0 ,
"stock" : 100 ,
"imagen" : "productos/abc123.jpg" ,
"imagen_url" : "http://localhost:8000/storage/productos/abc123.jpg"
}
Create Product
Endpoint: POST /api/empresa/productos
Authentication: Required (empresa role)
Request Fields:
Field Type Validation Description nombrestring required, max:255 Product name precionumeric required Product price stockinteger required, min:0 Available quantity categoria_idinteger required, exists:categorias,id Category ID descripciontext nullable Product description imagenfile nullable, image, max:2MB Product image
Example Request:
const formData = new FormData ();
formData . append ( 'nombre' , 'Café Americano' );
formData . append ( 'precio' , '5000' );
formData . append ( 'stock' , '100' );
formData . append ( 'categoria_id' , '2' );
formData . append ( 'descripcion' , 'Café negro preparado con granos colombianos' );
formData . append ( 'imagen' , imageFile );
const response = await fetch ( '/api/empresa/productos' , {
method: 'POST' ,
headers: {
'Authorization' : `Bearer ${ token } `
},
body: formData
});
Success Response:
{
"message" : "Producto creado con éxito" ,
"producto" : {
"id" : 15 ,
"empresa_id" : 5 ,
"categoria_id" : 2 ,
"nombre" : "Café Americano" ,
"descripcion" : "Café negro preparado con granos colombianos" ,
"precio" : 5000.0 ,
"stock" : 100 ,
"imagen" : "productos/xyz789.jpg" ,
"imagen_url" : "http://localhost:8000/storage/productos/xyz789.jpg"
}
}
Controller Logic:
public function store ( Request $request )
{
$request -> validate ([
'nombre' => 'required|string|max:255' ,
'precio' => 'required|numeric' ,
'stock' => 'required|integer|min:0' ,
'categoria_id' => 'required|exists:categorias,id' ,
'descripcion' => 'nullable|string' ,
'imagen' => 'nullable|image|max:2048' ,
]);
$empresa = Empresa :: where ( 'user_id' , Auth :: id ()) -> first ();
$producto = new Producto ();
$producto -> nombre = $request -> nombre ;
$producto -> descripcion = $request -> descripcion ;
$producto -> precio = $request -> precio ;
$producto -> stock = $request -> stock ;
$producto -> empresa_id = $empresa -> id ;
$producto -> categoria_id = $request -> categoria_id ;
if ( $request -> hasFile ( 'imagen' )) {
$producto -> imagen = $request -> file ( 'imagen' ) -> store ( 'productos' , 'public' );
}
$producto -> save ();
return response () -> json ([
'message' => 'Producto creado con éxito' ,
'producto' => $producto
], 201 );
}
Image Storage: Product images are stored in storage/app/public/productos/ and publicly accessible via public/storage/productos/.
Update Product
Endpoint: PUT /api/empresa/productos/{id}
Authentication: Required (empresa role)
Authorization: Product must belong to the authenticated business
Request Fields: Same as create (all fields required)
Example Request:
const formData = new FormData ();
formData . append ( 'nombre' , 'Café Americano Premium' );
formData . append ( 'precio' , '6000' );
formData . append ( 'stock' , '150' );
formData . append ( 'categoria_id' , '2' );
formData . append ( 'descripcion' , 'Café negro premium con granos de altura' );
formData . append ( '_method' , 'PUT' ); // Laravel method spoofing
// Only append imagen if user selected a new one
if ( newImageFile ) {
formData . append ( 'imagen' , newImageFile );
}
const response = await fetch ( `/api/empresa/productos/ ${ productId } ` , {
method: 'POST' , // Use POST with _method spoofing
headers: {
'Authorization' : `Bearer ${ token } `
},
body: formData
});
Success Response:
{
"message" : "¡Actualizado!" ,
"producto" : {
"id" : 15 ,
"nombre" : "Café Americano Premium" ,
"precio" : 6000.0 ,
"stock" : 150 ,
"imagen_url" : "http://localhost:8000/storage/productos/new-image.jpg"
}
}
Controller Logic:
public function update ( Request $request , $id ) : JsonResponse
{
$empresa = Empresa :: where ( 'user_id' , Auth :: id ()) -> first ();
$producto = Producto :: where ( 'id' , $id )
-> where ( 'empresa_id' , $empresa -> id )
-> first ();
if ( ! $producto ) {
return response () -> json ([ 'message' => 'No encontrado' ], 404 );
}
$request -> validate ([
'nombre' => 'required|string|max:255' ,
'precio' => 'required|numeric' ,
'stock' => 'required|integer' ,
'categoria_id' => 'required|exists:categorias,id' ,
]);
$producto -> nombre = $request -> input ( 'nombre' );
$producto -> descripcion = $request -> input ( 'descripcion' );
$producto -> precio = $request -> input ( 'precio' );
$producto -> stock = $request -> input ( 'stock' );
$producto -> categoria_id = $request -> input ( 'categoria_id' );
// Handle image update
if ( $request -> hasFile ( 'imagen' )) {
// Delete old image
if ( $producto -> imagen ) {
Storage :: disk ( 'public' ) -> delete ( $producto -> imagen );
}
$producto -> imagen = $request -> file ( 'imagen' ) -> store ( 'productos' , 'public' );
}
$producto -> save ();
return response () -> json ([
'message' => '¡Actualizado!' ,
'producto' => $producto
]);
}
Image Replacement: When updating a product with a new image, the old image file is automatically deleted from storage to prevent accumulation of unused files.
Delete Product
Endpoint: DELETE /api/empresa/productos/{id}
Authentication: Required (empresa role)
Authorization: Product must belong to the authenticated business
Success Response:
{
"message" : "Producto eliminado correctamente."
}
Controller Logic:
public function destroy ( $id ) : JsonResponse
{
$empresa = Empresa :: where ( 'user_id' , Auth :: id ()) -> first ();
$producto = Producto :: where ( 'id' , $id )
-> where ( 'empresa_id' , $empresa -> id )
-> first ();
if ( ! $producto ) {
return response () -> json ([ 'message' => 'No autorizado.' ], 403 );
}
// Delete image file
if ( $producto -> imagen ) {
Storage :: disk ( 'public' ) -> delete ( $producto -> imagen );
}
$producto -> delete ();
return response () -> json ([ 'message' => 'Producto eliminado correctamente.' ]);
}
Category Management
Categoria Model
Table: categorias
Fillable Fields:
Relationships:
productos() - hasMany Producto
Create Category (Admin Only)
Endpoint: POST /api/admin/categorias
Authentication: Required (admin role)
Request Fields:
Field Validation Description nombrerequired, string, unique:categorias,nombre Category name (must be unique)
Example Request:
{
"nombre" : "Bebidas Frías"
}
Success Response:
{
"message" : "Categoría creada correctamente." ,
"categoria" : {
"id" : 5 ,
"nombre" : "Bebidas Frías" ,
"created_at" : "2026-03-05T14:20:00.000000Z" ,
"updated_at" : "2026-03-05T14:20:00.000000Z"
}
}
Delete Category (Admin Only)
Endpoint: DELETE /api/admin/categorias/{id}
Authentication: Required (admin role)
Cascade Deletion: Deleting a category may affect products that reference it. Ensure proper foreign key constraints are configured in your database migrations.
Success Response:
{
"message" : "Categoría eliminada correctamente."
}
Featured Products (Home Page)
BeanQuick displays the top 4 highest-rated products on the homepage.
Endpoint: GET /api/productos/destacados
Authentication: Not required (public endpoint)
Response:
[
{
"id" : 12 ,
"nombre" : "Café Latte" ,
"precio" : 7500.0 ,
"imagen" : "productos/latte.jpg" ,
"imagen_url" : "http://localhost:8000/storage/productos/latte.jpg" ,
"empresa_id" : 3 ,
"empresa" : {
"id" : 3 ,
"nombre" : "Café del Centro" ,
"logo" : "empresas/logos/cafe-centro.jpg" ,
"logo_url" : "http://localhost:8000/storage/empresas/logos/cafe-centro.jpg"
},
"calificaciones_avg_estrellas" : 4.8
},
{
"id" : 8 ,
"nombre" : "Cappuccino" ,
"precio" : 8000.0 ,
"imagen_url" : "http://localhost:8000/storage/productos/cappuccino.jpg" ,
"empresa" : {
"nombre" : "Espresso Bar" ,
"logo_url" : "http://localhost:8000/storage/empresas/logos/espresso.jpg"
},
"calificaciones_avg_estrellas" : 4.7
}
// ... 2 more products
]
Controller Logic:
public function destacados () : JsonResponse
{
try {
// Load products with relationships
$productos = Producto :: select ( 'id' , 'nombre' , 'precio' , 'imagen' , 'empresa_id' )
-> with ([ 'empresa:id,nombre,logo' , 'calificaciones:id,producto_id,estrellas' ])
-> get ();
$destacados = $productos -> map ( function ( $producto ) {
// Calculate average rating
$promedio = $producto -> calificaciones -> avg ( 'estrellas' );
$producto -> calificaciones_avg_estrellas = $promedio ? round ( $promedio , 1 ) : 0 ;
// Generate full logo URL for empresa
if ( $producto -> empresa ) {
$producto -> empresa -> logo_url = $producto -> empresa -> logo
? asset ( 'storage/' . $producto -> empresa -> logo )
: asset ( 'images/default-logo.png' );
}
// Remove calificaciones to keep JSON light
unset ( $producto -> calificaciones );
return $producto ;
})
// Filter only rated products and take top 4
-> filter ( fn ( $p ) => $p -> calificaciones_avg_estrellas > 0 )
-> sortByDesc ( 'calificaciones_avg_estrellas' )
-> take ( 4 )
-> values ();
return response () -> json ( $destacados );
} catch ( \ Exception $e ) {
return response () -> json ([
'error' => 'Error al procesar destacados' ,
'details' => $e -> getMessage ()
], 500 );
}
}
Stock Management
Stock Validation
Stock is validated at multiple points in the order flow:
Adding to Cart - Validates available stock before allowing add
Updating Cart - Re-validates when customer changes quantity
Creating Order - Final validation before order creation (order created but stock NOT deducted yet)
Payment Confirmation - Stock is deducted only after payment is approved
Stock Deduction Flow
Order Created
Order is created with estado_pago: 'pendiente'. Stock is validated but NOT deducted.
Payment Processed
Customer completes payment via Mercado Pago.
Webhook Receives Confirmation
When payment status is 'approved', webhook triggers stock deduction: foreach ( $pedido -> productos as $producto ) {
$cantidad = $producto -> pivot -> cantidad ;
$producto -> decrement ( 'stock' , $cantidad );
}
Order Status Updated
Order status changes to estado: 'Pagado' and estado_pago: 'aprobado'.
Stock Restoration
Stock is restored when:
Customer cancels a paid order (only paid orders have deducted stock)
Payment is refunded or charged back
if ( $pedido -> estado_pago === 'aprobado' ) {
foreach ( $pedido -> productos as $producto ) {
$producto -> increment ( 'stock' , $producto -> pivot -> cantidad );
}
}
Implementation Example
import { useState , useEffect } from 'react' ;
function ProductForm ({ productId = null , onSuccess }) {
const [ formData , setFormData ] = useState ({
nombre: '' ,
precio: '' ,
stock: '' ,
categoria_id: '' ,
descripcion: '' ,
imagen: null
});
const [ categorias , setCategorias ] = useState ([]);
const [ loading , setLoading ] = useState ( false );
useEffect (() => {
// Load categories
fetch ( '/api/categorias' )
. then ( res => res . json ())
. then ( data => setCategorias ( data ));
// If editing, load product data
if ( productId ) {
fetch ( `/api/empresa/productos/ ${ productId } ` , {
headers: {
'Authorization' : `Bearer ${ localStorage . getItem ( 'token' ) } `
}
})
. then ( res => res . json ())
. then ( product => {
setFormData ({
nombre: product . nombre ,
precio: product . precio ,
stock: product . stock ,
categoria_id: product . categoria_id ,
descripcion: product . descripcion || '' ,
imagen: null // Don't load existing image
});
});
}
}, [ productId ]);
const handleSubmit = async ( e ) => {
e . preventDefault ();
setLoading ( true );
const data = new FormData ();
Object . keys ( formData ). forEach ( key => {
if ( formData [ key ] !== null ) {
data . append ( key , formData [ key ]);
}
});
const url = productId
? `/api/empresa/productos/ ${ productId } `
: '/api/empresa/productos' ;
const method = productId ? 'POST' : 'POST' ; // Both use POST
if ( productId ) data . append ( '_method' , 'PUT' ); // Method spoofing for update
try {
const response = await fetch ( url , {
method ,
headers: {
'Authorization' : `Bearer ${ localStorage . getItem ( 'token' ) } `
},
body: data
});
const result = await response . json ();
if ( response . ok ) {
alert ( result . message );
if ( onSuccess ) onSuccess ( result . producto );
} else {
alert ( result . message || 'Error al guardar producto' );
}
} catch ( error ) {
console . error ( 'Error:' , error );
alert ( 'Error de conexión' );
} finally {
setLoading ( false );
}
};
return (
< form onSubmit = { handleSubmit } >
< input
type = "text"
placeholder = "Nombre del producto"
value = { formData . nombre }
onChange = { ( e ) => setFormData ({ ... formData , nombre: e . target . value }) }
required
/>
< input
type = "number"
placeholder = "Precio"
value = { formData . precio }
onChange = { ( e ) => setFormData ({ ... formData , precio: e . target . value }) }
required
min = "0"
step = "0.01"
/>
< input
type = "number"
placeholder = "Stock disponible"
value = { formData . stock }
onChange = { ( e ) => setFormData ({ ... formData , stock: e . target . value }) }
required
min = "0"
/>
< select
value = { formData . categoria_id }
onChange = { ( e ) => setFormData ({ ... formData , categoria_id: e . target . value }) }
required
>
< option value = "" > Seleccionar categoría </ option >
{ categorias . map ( cat => (
< option key = { cat . id } value = { cat . id } > { cat . nombre } </ option >
)) }
</ select >
< textarea
placeholder = "Descripción (opcional)"
value = { formData . descripcion }
onChange = { ( e ) => setFormData ({ ... formData , descripcion: e . target . value }) }
/>
< input
type = "file"
accept = "image/*"
onChange = { ( e ) => setFormData ({ ... formData , imagen: e . target . files [ 0 ]}) }
/>
< button type = "submit" disabled = { loading } >
{ loading ? 'Guardando...' : ( productId ? 'Actualizar' : 'Crear' ) } Producto
</ button >
</ form >
);
}
Best Practices
Image Optimization Compress images before upload. Recommended: 800x800px, under 500KB, WebP format for best performance.
Stock Accuracy Always validate stock in real-time before critical operations. Never trust frontend stock values.
Category Management Create categories before allowing product creation. Implement category-based filtering for better UX.
Authorization Always verify product ownership before update/delete operations to prevent unauthorized access.
Shopping Cart Learn how customers add products to cart and manage quantities
Ratings & Reviews Understand how product ratings affect featured products display