Backend & architecture
Building a Laravel backend for an Ionic shop: migrations, seeders and RESTful controllers
6 min read
Building a Laravel backend for an Ionic shop: migrations, seeders and RESTful controllers
My master's final project was a multiplatform e-commerce app: an Ionic/Angular client for customers and a Laravel backend that served both the data the app consumed and an admin panel to manage products, categories, customers and orders. It got the highest grade (Matrícula de Honor), but what I want to write down here is less the result and more the decisions on the Laravel side that I would make the same way again, and a couple that I would not.
Splitting the work before writing code
The first useful thing I did was split the backlog into four blocks instead of one long list:
- Client: everything the customer sees in the Ionic app: search bar, cart, favourites, product detail, profile, order history.
- Admin: the Laravel panel: tables with filters, forms to add and edit categories and customers, exports.
- Connection: the contract between both sides.
- Tests.
Looking back at that backlog, the client and admin blocks are full of checked items, while "connection" and "tests" stayed almost empty. That is the most honest lesson of this post: the integration between client and server, and the tests that protect it, deserve their own tasks from day one. If they only exist as a heading, they get done implicitly and late.
Migrations: the order is part of the design
I wrote one note in capital letters while setting up the database: the order in which you create tables matters. Laravel runs migrations in filename order, and a table with a foreign key can only be created after the table it points to exists. In a shop that chain is long: users, categories, products, orders, order lines, comments, favourites.
There are two ways to declare a foreign key, and I ended up preferring the short one:
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete()
->cascadeOnUpdate();
$table->timestamps();
});
constrained() infers the referenced table from the column name (user_id → users), which only works if you follow Laravel's naming conventions. That is the other rule I wrote down early: table names in lowercase and plural, because Eloquent maps the Product model to the products table automatically. Fighting that convention costs more than it saves.
The down() method deserves the same care: drop foreign keys before dropping the columns or tables they depend on, otherwise migrate:rollback fails halfway.
I also moved the schema design to dbdiagram.io early on. Seeing the relationships drawn made it obvious which migration had to come first.
A data modelling question I wrote down
One of the doubts I logged during a sprint was whether the orders table needed a price column at all, since the total can be computed from the order lines. The answer I settled on: it is not strictly necessary, but keeping it is a reasonable trade-off for efficiency when listing many orders, as long as it is updated whenever the lines change. It is a small decision, but having it written down meant I did not have to argue it again every time I touched the orders screen.
Seeders: realistic data from the first week
Seeders made the admin panel testable before any real data existed. The pattern is simple: an array of records, a private method that inserts them, and a call from run():
class DatabaseSeeder extends Seeder
{
private array $users = [
['name' => 'Demo Admin', 'email' => 'admin@example.com', 'password' => 'secret'],
['name' => 'Demo Customer', 'email' => 'customer@example.com', 'password' => 'secret'],
];
public function run(): void
{
$this->seedUsers();
$this->command->info('Users table seeded.');
}
private function seedUsers(): void
{
foreach ($this->users as $data) {
User::create([...$data, 'password' => bcrypt($data['password'])]);
}
}
}
During development, php artisan migrate:refresh --seed became my reset button: it rebuilds the schema, reloads the data, and every screen is back to a known state. Passwords go through bcrypt() even in seed data; it is a habit worth having from the start.
Eloquent or Query Builder
Laravel gives you two layers over the database. The Query Builder is a fluent way of writing SQL operations; Eloquent is the ORM on top of it, where each row comes back as an instance of a model. For an admin panel full of CRUD screens, Eloquent was the natural default:
$product = Product::findOrFail($id); // 404 instead of a null check
$recent = Order::where('price', '>', 100)->latest()->take(10)->get();
$count = Customer::count();
$average = Order::avg('price');
findOrFail() removed a whole category of "trying to read property of null" errors from the controllers. The aggregate helpers (count, max, min, avg, sum) covered the summary figures of the panel without writing raw SQL. I kept the Query Builder (DB::table(...)) for the few places where I wanted a plain operation on a set of rows and did not need models at all, such as clearing a table inside a seeder.
RESTful controllers
For every resource the panel and the app needed (products, categories, customers, orders) I used resource controllers:
php artisan make:controller ProductController --resource
Route::resource('products', ProductController::class);
One command gives you the seven standard actions (index, create, store, show, edit, update, destroy) and one line registers all their routes, which php artisan route:list lets you check at a glance. For endpoints consumed by a mobile client rather than by Blade forms, Route::apiResource() registers the same thing without the create and edit actions, which only make sense for HTML forms.
The value is not the typing you save; it is predictability. Every resource has the same shape, so both the Ionic side and the admin side always knew which URL and which verb to use.
The admin panel: where Laravel's defaults ran out
Laravel's built-in pagination was the first thing I tried for the admin tables, and it works, but it does not include live search or export buttons. I moved those tables to DataTables, which filters in real time on the client, and added PDF and Excel exports for products, customers and orders. DataTables has limits of its own, and with a real catalogue I would move the filtering to the server; for a final project it was enough.
Product images: storage, not the public folder
Product and profile images go through Laravel's storage layer. Files written to the public disk live in storage/app/public, and php artisan storage:link exposes them under /storage. Using $request->file('image')->store('products', 'public') also stores the file under a generated hashed name instead of the name the user uploaded, which avoids filenames full of quotes, HTML or odd characters.
The one real snag was size: uploads above PHP's defaults (upload_max_filesize = 2M, post_max_size = 8M) failed. Both values have to be raised in php.ini, with post_max_size at least as large as the upload limit, and the server restarted. At the time I found the active php.ini by dropping a phpinfo() file into public/, a shortcut I now treat as an anti-pattern: php --ini gives you the same path from the terminal, and a phpinfo() page left in a public folder hands your whole server configuration to anyone who finds it. I also resized images on the server so the app was not downloading full-size photos for thumbnails.
What I would keep, and what I would change
I would keep the conventions (plural lowercase tables, foreignId()->constrained(), resource controllers, seeders as a reset button) because they let me move fast without thinking about plumbing. I would change two things: give the client–server contract and the tests their own backlog items from the first sprint, and design the admin tables with server-side filtering from the start. Neither would have changed the grade; both would have made the codebase easier to keep growing.