Mapa del proyecto
Vamos a construir un sistema de tienda con estas cinco tablas y sus relaciones:
Category ──hasMany──► Product ◄──belongsToMany──► Order ──belongsTo──► Client
Tablas físicas:
categories → id, name, description
clients → id, name, email, phone
products → id, name, price, stock, category_id (FK)
orders → id, date, status, client_id (FK)
order_product → order_id, product_id, quantity ← pivot
Al terminar el panel de MoonShine mostrará:
- Categorías con tabla de productos en la vista detalle (
HasMany) - Productos con dropdown de categoría en el formulario (
BelongsTo) - Clientes con tabla de pedidos en la vista detalle (
HasMany) - Pedidos con selector de cliente y selector múltiple de productos con campo cantidad (
BelongsToMany+ pivot)
Instalación
composer create-project laravel/laravel tienda
cd tienda
composer require moonshine/moonshine
php artisan moonshine:install
El instalador crea el usuario admin, publica el layout, el service provider y las rutas del panel. Configura tu base de datos en .env:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=tienda
DB_USERNAME=root
DB_PASSWORD=
Migraciones
Creá las migraciones respetando el orden de las FK — primero las tablas independientes, al final la pivot:
php artisan make:migration create_categories_table
php artisan make:migration create_clients_table
php artisan make:migration create_products_table
php artisan make:migration create_orders_table
php artisan make:migration create_order_product_table
categories
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
});
clients
Schema::create('clients', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->string('phone')->nullable();
$table->timestamps();
});
products — FK a categories:
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->decimal('price', 10, 2);
$table->integer('stock')->default(0);
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->timestamps();
});
orders — FK a clients, con campo enum para estados:
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->date('date');
$table->enum('status', ['pendiente','enviado','entregado','cancelado'])
->default('pendiente');
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
$table->timestamps();
});
order_product — tabla pivot con campo extra quantity:
Schema::create('order_product', function (Blueprint $table) {
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->integer('quantity')->default(1);
$table->primary(['order_id', 'product_id']);
});
php artisan migrate
Modelos y relaciones
Category
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Category extends Model
{
protected $fillable = ['name', 'description'];
public function products()
{
return $this->hasMany(Product::class);
}
}
Client
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Client extends Model
{
protected $fillable = ['name', 'email', 'phone'];
public function orders()
{
return $this->hasMany(Order::class);
}
}
Product — pertenece a una categoría y puede estar en muchos pedidos:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $fillable = ['name', 'price', 'stock', 'category_id'];
public function category()
{
return $this->belongsTo(Category::class);
}
public function orders()
{
return $this->belongsToMany(Order::class)
->withPivot('quantity')
->withTimestamps();
}
}
Order — pertenece a un cliente y tiene muchos productos con campo pivot:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $fillable = ['date', 'status', 'client_id'];
protected $casts = ['date' => 'date'];
public function client()
{
return $this->belongsTo(Client::class);
}
public function products()
{
return $this->belongsToMany(Product::class)
->withPivot('quantity')
->withTimestamps();
}
}
withPivot('quantity') es obligatorio en ambos lados de la relación. Sin él MoonShine no puede leer ni guardar el campo quantity de la tabla pivot.
Arquitectura de Resources en MoonShine 4
En MoonShine 4 cada resource genera 4 archivos organizados en subcarpetas — una diferencia clave respecto a MoonShine 3:
php artisan moonshine:resource CategoryResource
php artisan moonshine:resource ClientResource
php artisan moonshine:resource ProductResource
php artisan moonshine:resource OrderResource
Estructura generada para cada resource:
app/MoonShine/Resources/
└── Category/
├── CategoryResource.php ← registra las 3 páginas
└── Pages/
├── CategoryIndexPage.php ← listado / tabla
├── CategoryFormPage.php ← formulario crear/editar + validación
└── CategoryDetailPage.php ← vista detalle del registro
Cada página define sus propios fields(). El resource principal solo referencia las páginas.
Resource: Category
CategoryResource.php
<?php
namespace App\MoonShine\Resources\Category;
use App\Models\Category;
use App\MoonShine\Resources\Category\Pages\CategoryDetailPage;
use App\MoonShine\Resources\Category\Pages\CategoryFormPage;
use App\MoonShine\Resources\Category\Pages\CategoryIndexPage;
use MoonShine\Laravel\Resources\ModelResource;
class CategoryResource extends ModelResource
{
protected string $model = Category::class;
protected string $title = 'Categorías';
protected string $column = 'name';
protected function pages(): array
{
return [
CategoryIndexPage::class,
CategoryFormPage::class,
CategoryDetailPage::class,
];
}
}
CategoryIndexPage.php — columnas del listado:
<?php
namespace App\MoonShine\Resources\Category\Pages;
use MoonShine\Laravel\Pages\Crud\IndexPage;
use MoonShine\UI\Fields\ID;
use MoonShine\UI\Fields\Text;
use MoonShine\UI\Fields\Textarea;
class CategoryIndexPage extends IndexPage
{
protected function fields(): iterable
{
return [
ID::make()->sortable(),
Text::make('Nombre', 'name'),
Textarea::make('Descripción', 'description'),
];
}
}
CategoryFormPage.php — formulario + validación:
<?php
namespace App\MoonShine\Resources\Category\Pages;
use MoonShine\Laravel\Pages\Crud\FormPage;
use MoonShine\UI\Fields\ID;
use MoonShine\UI\Fields\Text;
use MoonShine\UI\Fields\Textarea;
class CategoryFormPage extends FormPage
{
protected function fields(): iterable
{
return [
ID::make(),
Text::make('Nombre', 'name')->required(),
Textarea::make('Descripción', 'description'),
];
}
protected function rules(mixed $item): array
{
return [
'name' => ['required', 'string', 'max:255'],
];
}
}
CategoryDetailPage.php — detalle con tabla de productos relacionados (HasMany):
<?php
namespace App\MoonShine\Resources\Category\Pages;
use App\MoonShine\Resources\Product\ProductResource;
use MoonShine\Laravel\Fields\Relationships\HasMany;
use MoonShine\Laravel\Pages\Crud\DetailPage;
use MoonShine\UI\Fields\ID;
use MoonShine\UI\Fields\Text;
use MoonShine\UI\Fields\Textarea;
class CategoryDetailPage extends DetailPage
{
protected function fields(): iterable
{
return [
ID::make(),
Text::make('Nombre', 'name'),
Textarea::make('Descripción', 'description'),
HasMany::make('Productos', 'products', resource: ProductResource::class),
];
}
}
Resource: Client
ClientResource.php
<?php
namespace App\MoonShine\Resources\Client;
use App\Models\Client;
use App\MoonShine\Resources\Client\Pages\ClientDetailPage;
use App\MoonShine\Resources\Client\Pages\ClientFormPage;
use App\MoonShine\Resources\Client\Pages\ClientIndexPage;
use MoonShine\Laravel\Resources\ModelResource;
class ClientResource extends ModelResource
{
protected string $model = Client::class;
protected string $title = 'Clientes';
protected string $column = 'name';
protected function pages(): array
{
return [
ClientIndexPage::class,
ClientFormPage::class,
ClientDetailPage::class,
];
}
}
ClientIndexPage.php
<?php
namespace App\MoonShine\Resources\Client\Pages;
use MoonShine\Laravel\Pages\Crud\IndexPage;
use MoonShine\UI\Fields\Email;
use MoonShine\UI\Fields\ID;
use MoonShine\UI\Fields\Phone;
use MoonShine\UI\Fields\Text;
class ClientIndexPage extends IndexPage
{
protected function fields(): iterable
{
return [
ID::make()->sortable(),
Text::make('Nombre', 'name'),
Email::make('Correo', 'email'),
Phone::make('Teléfono', 'phone'),
];
}
}
ClientFormPage.php — validación de email único ignorando el propio registro:
<?php
namespace App\MoonShine\Resources\Client\Pages;
use MoonShine\Laravel\Pages\Crud\FormPage;
use MoonShine\UI\Fields\Email;
use MoonShine\UI\Fields\ID;
use MoonShine\UI\Fields\Phone;
use MoonShine\UI\Fields\Text;
class ClientFormPage extends FormPage
{
protected function fields(): iterable
{
return [
ID::make(),
Text::make('Nombre', 'name')->required(),
Email::make('Correo', 'email')->required(),
Phone::make('Teléfono', 'phone'),
];
}
protected function rules(mixed $item): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required', 'email',
'unique:clients,email,' . ($item?->id ?? 'NULL'),
],
];
}
}
ClientDetailPage.php — pedidos del cliente como HasMany:
<?php
namespace App\MoonShine\Resources\Client\Pages;
use App\MoonShine\Resources\Order\OrderResource;
use MoonShine\Laravel\Fields\Relationships\HasMany;
use MoonShine\Laravel\Pages\Crud\DetailPage;
use MoonShine\UI\Fields\Email;
use MoonShine\UI\Fields\ID;
use MoonShine\UI\Fields\Phone;
use MoonShine\UI\Fields\Text;
class ClientDetailPage extends DetailPage
{
protected function fields(): iterable
{
return [
ID::make(),
Text::make('Nombre', 'name'),
Email::make('Correo', 'email'),
Phone::make('Teléfono', 'phone'),
HasMany::make('Pedidos', 'orders', resource: OrderResource::class),
];
}
}
Resource: Product
ProductResource.php — carga eager la categoría con $with:
<?php
namespace App\MoonShine\Resources\Product;
use App\Models\Product;
use App\MoonShine\Resources\Product\Pages\ProductDetailPage;
use App\MoonShine\Resources\Product\Pages\ProductFormPage;
use App\MoonShine\Resources\Product\Pages\ProductIndexPage;
use MoonShine\Laravel\Resources\ModelResource;
class ProductResource extends ModelResource
{
protected string $model = Product::class;
protected string $title = 'Productos';
protected string $column = 'name';
protected array $with = ['category'];
protected function pages(): array
{
return [
ProductIndexPage::class,
ProductFormPage::class,
ProductDetailPage::class,
];
}
}
ProductIndexPage.php — muestra la categoría con BelongsTo:
<?php
namespace App\MoonShine\Resources\Product\Pages;
use App\MoonShine\Resources\Category\CategoryResource;
use MoonShine\Laravel\Fields\Relationships\BelongsTo;
use MoonShine\Laravel\Pages\Crud\IndexPage;
use MoonShine\UI\Fields\ID;
use MoonShine\UI\Fields\Number;
use MoonShine\UI\Fields\Text;
class ProductIndexPage extends IndexPage
{
protected function fields(): iterable
{
return [
ID::make()->sortable(),
Text::make('Nombre', 'name'),
Number::make('Precio', 'price'),
Number::make('Stock', 'stock'),
BelongsTo::make('Categoría', 'category', resource: CategoryResource::class),
];
}
}
ProductFormPage.php — dropdown de categorías en el formulario:
<?php
namespace App\MoonShine\Resources\Product\Pages;
use App\MoonShine\Resources\Category\CategoryResource;
use MoonShine\Laravel\Fields\Relationships\BelongsTo;
use MoonShine\Laravel\Pages\Crud\FormPage;
use MoonShine\UI\Fields\ID;
use MoonShine\UI\Fields\Number;
use MoonShine\UI\Fields\Text;
class ProductFormPage extends FormPage
{
protected function fields(): iterable
{
return [
ID::make(),
Text::make('Nombre', 'name')->required(),
Number::make('Precio', 'price')->min(0)->required(),
Number::make('Stock', 'stock')->min(0)->default(0),
BelongsTo::make('Categoría', 'category', resource: CategoryResource::class)
->required(),
];
}
protected function rules(mixed $item): array
{
return [
'name' => ['required', 'string', 'max:255'],
'price' => ['required', 'numeric', 'min:0'],
'stock' => ['integer', 'min:0'],
'category_id' => ['required', 'exists:categories,id'],
];
}
}
Resource: Order — BelongsToMany con pivot
Este es el resource más complejo y el que más preguntas genera. El formulario de pedidos necesita seleccionar múltiples productos y registrar una cantidad por cada uno en la tabla pivot.
OrderResource.php
<?php
namespace App\MoonShine\Resources\Order;
use App\Models\Order;
use App\MoonShine\Resources\Order\Pages\OrderDetailPage;
use App\MoonShine\Resources\Order\Pages\OrderFormPage;
use App\MoonShine\Resources\Order\Pages\OrderIndexPage;
use MoonShine\Laravel\Resources\ModelResource;
class OrderResource extends ModelResource
{
protected string $model = Order::class;
protected string $title = 'Pedidos';
protected string $column = 'id';
protected array $with = ['client', 'products'];
protected function pages(): array
{
return [
OrderIndexPage::class,
OrderFormPage::class,
OrderDetailPage::class,
];
}
}
OrderIndexPage.php
<?php
namespace App\MoonShine\Resources\Order\Pages;
use App\MoonShine\Resources\Client\ClientResource;
use MoonShine\Laravel\Fields\Relationships\BelongsTo;
use MoonShine\Laravel\Pages\Crud\IndexPage;
use MoonShine\UI\Fields\Date;
use MoonShine\UI\Fields\ID;
use MoonShine\UI\Fields\Select;
class OrderIndexPage extends IndexPage
{
protected function fields(): iterable
{
return [
ID::make()->sortable(),
Date::make('Fecha', 'date'),
Select::make('Estado', 'status')
->options([
'pendiente' => 'Pendiente',
'enviado' => 'Enviado',
'entregado' => 'Entregado',
'cancelado' => 'Cancelado',
]),
BelongsTo::make('Cliente', 'client', resource: ClientResource::class),
];
}
}
OrderFormPage.php — el campo clave: BelongsToMany con fields() para el pivot:
<?php
namespace App\MoonShine\Resources\Order\Pages;
use App\MoonShine\Resources\Client\ClientResource;
use App\MoonShine\Resources\Product\ProductResource;
use MoonShine\Laravel\Fields\Relationships\BelongsTo;
use MoonShine\Laravel\Fields\Relationships\BelongsToMany;
use MoonShine\Laravel\Pages\Crud\FormPage;
use MoonShine\UI\Fields\Date;
use MoonShine\UI\Fields\ID;
use MoonShine\UI\Fields\Number;
use MoonShine\UI\Fields\Select;
class OrderFormPage extends FormPage
{
protected function fields(): iterable
{
return [
ID::make(),
Date::make('Fecha', 'date')->required(),
Select::make('Estado', 'status')
->options([
'pendiente' => 'Pendiente',
'enviado' => 'Enviado',
'entregado' => 'Entregado',
'cancelado' => 'Cancelado',
])
->default('pendiente')
->required(),
BelongsTo::make('Cliente', 'client', resource: ClientResource::class)
->required(),
BelongsToMany::make('Productos', 'products', resource: ProductResource::class)
->fields([
Number::make('Cantidad', 'quantity')->min(1)->default(1),
]),
];
}
protected function rules(mixed $item): array
{
return [
'date' => ['required', 'date'],
'status' => ['required', 'in:pendiente,enviado,entregado,cancelado'],
'client_id' => ['required', 'exists:clients,id'],
];
}
}
->fields([]) dentro de BelongsToMany define los campos extras de la tabla pivot. El nombre del campo ('quantity') debe coincidir exactamente con el nombre de la columna en order_product.
OrderDetailPage.php
<?php
namespace App\MoonShine\Resources\Order\Pages;
use App\MoonShine\Resources\Client\ClientResource;
use App\MoonShine\Resources\Product\ProductResource;
use MoonShine\Laravel\Fields\Relationships\BelongsTo;
use MoonShine\Laravel\Fields\Relationships\BelongsToMany;
use MoonShine\Laravel\Pages\Crud\DetailPage;
use MoonShine\UI\Fields\Date;
use MoonShine\UI\Fields\ID;
use MoonShine\UI\Fields\Number;
use MoonShine\UI\Fields\Select;
class OrderDetailPage extends DetailPage
{
protected function fields(): iterable
{
return [
ID::make(),
Date::make('Fecha', 'date'),
Select::make('Estado', 'status')
->options([
'pendiente' => 'Pendiente',
'enviado' => 'Enviado',
'entregado' => 'Entregado',
'cancelado' => 'Cancelado',
]),
BelongsTo::make('Cliente', 'client', resource: ClientResource::class),
BelongsToMany::make('Productos', 'products', resource: ProductResource::class)
->fields([
Number::make('Cantidad', 'quantity'),
]),
];
}
}
Service Provider
Registrá los cuatro resources en app/Providers/MoonShineServiceProvider.php:
<?php
namespace App\Providers;
use App\MoonShine\Resources\Category\CategoryResource;
use App\MoonShine\Resources\Client\ClientResource;
use App\MoonShine\Resources\Order\OrderResource;
use App\MoonShine\Resources\Product\ProductResource;
use Illuminate\Support\ServiceProvider;
use MoonShine\Contracts\Core\DependencyInjection\CoreContract;
use MoonShine\Laravel\DependencyInjection\ConfiguratorContract;
use MoonShine\Laravel\Resources\MoonShineUserResource;
use MoonShine\Laravel\Resources\MoonShineUserRoleResource;
class MoonShineServiceProvider extends ServiceProvider
{
public function boot(
CoreContract $core,
ConfiguratorContract $config,
): void {
$core
->resources([
MoonShineUserResource::class,
MoonShineUserRoleResource::class,
CategoryResource::class,
ClientResource::class,
ProductResource::class,
OrderResource::class,
])
->pages([
...$config->getPages(),
]);
}
}
boot() con $core->autoload();. MoonShine detecta automáticamente todos los Resources dentro de app/MoonShine/. En producción ejecutá php artisan optimize.
Menú en el Layout
En MoonShine 4 el menú del sidebar se define en el Layout, no en el ServiceProvider como en versiones anteriores. Abrí app/MoonShine/Layouts/MoonShineLayout.php y buscá el método menu():
use App\MoonShine\Resources\Category\CategoryResource;
use App\MoonShine\Resources\Client\ClientResource;
use App\MoonShine\Resources\Order\OrderResource;
use App\MoonShine\Resources\Product\ProductResource;
use MoonShine\Laravel\Resources\MoonShineUserResource;
use MoonShine\Laravel\Resources\MoonShineUserRoleResource;
use MoonShine\MenuManager\MenuGroup;
use MoonShine\MenuManager\MenuItem;
protected function menu(): array
{
return [
MenuGroup::make('Catálogo', [
MenuItem::make('Categorías', CategoryResource::class)
->icon('s.tag'),
MenuItem::make('Productos', ProductResource::class)
->icon('s.shopping-bag'),
])->icon('s.squares-2x2'),
MenuGroup::make('Ventas', [
MenuItem::make('Clientes', ClientResource::class)
->icon('s.users'),
MenuItem::make('Pedidos', OrderResource::class)
->icon('s.clipboard-document-list'),
])->icon('s.chart-bar'),
MenuGroup::make('Administración', [
MenuItem::make('Usuarios', MoonShineUserResource::class)
->icon('s.user'),
MenuItem::make('Roles', MoonShineUserRoleResource::class)
->icon('s.shield-check'),
])->icon('s.cog-6-tooth'),
];
}
s. = solid · o. = outline · m. = mini. Referencia completa en heroicons.com.
Seeder de prueba
Cargá datos de prueba en database/seeders/DatabaseSeeder.php:
<?php
namespace Database\Seeders;
use App\Models\Category;
use App\Models\Client;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
// Categorías
$electronica = Category::create(['name' => 'Electrónica', 'description' => 'Dispositivos y gadgets']);
$ropa = Category::create(['name' => 'Ropa', 'description' => 'Prendas de vestir']);
$alimentos = Category::create(['name' => 'Alimentos', 'description' => 'Comida y bebidas']);
// Productos
$p1 = Product::create(['name' => 'Audífonos Bluetooth', 'price' => 89000, 'stock' => 15, 'category_id' => $electronica->id]);
$p2 = Product::create(['name' => 'Camiseta Polo', 'price' => 45000, 'stock' => 30, 'category_id' => $ropa->id]);
$p3 = Product::create(['name' => 'Café Tostado 500g', 'price' => 18000, 'stock' => 50, 'category_id' => $alimentos->id]);
$p4 = Product::create(['name' => 'Teclado Mecánico', 'price' => 210000, 'stock' => 8, 'category_id' => $electronica->id]);
// Clientes
$c1 = Client::create(['name' => 'Ana Torres', 'email' => 'ana@correo.com', 'phone' => '3001234567']);
$c2 = Client::create(['name' => 'Luis Gómez', 'email' => 'luis@correo.com', 'phone' => '3109876543']);
$c3 = Client::create(['name' => 'María Pérez', 'email' => 'maria@correo.com', 'phone' => '3207654321']);
// Pedidos con productos (relación pivot)
$o1 = Order::create(['date' => '2025-01-15', 'status' => 'entregado', 'client_id' => $c1->id]);
$o1->products()->attach([
$p1->id => ['quantity' => 2],
$p3->id => ['quantity' => 1],
]);
$o2 = Order::create(['date' => '2025-01-20', 'status' => 'enviado', 'client_id' => $c2->id]);
$o2->products()->attach([
$p4->id => ['quantity' => 1],
$p2->id => ['quantity' => 3],
]);
$o3 = Order::create(['date' => '2025-01-22', 'status' => 'pendiente', 'client_id' => $c3->id]);
$o3->products()->attach([
$p2->id => ['quantity' => 2],
]);
}
}
php artisan db:seed
Verificación y errores comunes
php artisan config:clear
php artisan cache:clear
php artisan serve
Abrí http://127.0.0.1:8000/moonshine e iniciá sesión con el usuario creado durante la instalación. Verificá esto:
- Sidebar muestra los grupos "Catálogo" y "Ventas" con íconos
- Categorías → detalle → tabla de productos de esa categoría (HasMany)
- Clientes → detalle → tabla de pedidos de ese cliente (HasMany)
- Productos → formulario → dropdown de categorías (BelongsTo)
- Pedidos → formulario → dropdown de clientes y selector múltiple de productos con campo cantidad (BelongsToMany + pivot)
Errores comunes:
ERROR: Class not found en los Pages de un resource
→ Los namespaces deben coincidir exactamente con la ruta del archivo.
Archivo en Resources/Category/Pages/CategoryFormPage.php
debe tener: namespace App\MoonShine\Resources\Category\Pages;
ERROR: Column not found: quantity (en el pivot)
→ Verificá que order_product tenga el campo 'quantity'.
→ Los modelos Product y Order deben tener withPivot('quantity').
ERROR: BelongsToMany no guarda los productos
→ Verificá withPivot('quantity') y withTimestamps() en ambos modelos.
→ El campo Number en el FormPage debe ser: Number::make('Cantidad', 'quantity')
ERROR: Menú no aparece o íconos en blanco
→ El método menu() va en MoonShineLayout.php, NO en el ServiceProvider.
→ Usá prefijos s. o. m. para los íconos (Heroicons v2).
ERROR: Resource no aparece en el panel
→ Verificá que esté listado en el boot() del ServiceProvider.
→ O usá $core->autoload() y ejecutá composer dump-autoload.