Web · Lessons
Shipping a food delivery product solo: Laravel, Ionic and lessons
In early 2019 I shipped a complete food delivery product on my own: a backend with an admin panel, a customer facing website, and a hybrid mobile app. It was the largest thing I had built solo up to that point, and it was the last big freelance project before I moved into trading tools later that year. This is a write-up of how the three pieces fit together and what I took away from doing all of it alone.
The code lives in three repositories: food_delivery_app_backend_admin, food_delivery_app_frontend and food_delivery_app. All three were pushed in February 2019.
The problem
A food delivery business needs the same handful of things regardless of its size: a catalogue of restaurants and menus, a way for customers to search and order, a way for the business to see and manage orders, and a way to tell the customer what is happening. The client wanted a website and an Android app, both talking to the same data, and an admin screen that staff could use without training.
As a solo developer the hard part is not any one of those. It is keeping three codebases consistent while you are the only person who knows how they connect.
What I built
I split the product into three layers that could be deployed independently.
1. Laravel backend and admin
The backend is Laravel on MySQL. It exposes a REST API for the two clients and ships an admin panel for restaurant, menu and order management. The admin has basic reporting (orders over time, by restaurant, by status) and an SMS API integration so customers get a text when an order is accepted or out for delivery.
2. Bootstrap 3 web frontend
The website is a conventional Bootstrap 3 UI. The important interaction is search: as you type, an AJAX call hits the API and the results list updates without a page reload. The cart is client side until checkout, where the order is posted to the API and the customer is handed over to a payment gateway.
3. Ionic / AngularJS mobile app
The app is Ionic 1 on AngularJS, one codebase packaged for Android and iOS with Cordova. It consumes the same web services as the website, so a menu change in the admin shows up in both clients immediately.
How it works
The whole product hangs off a small set of API routes. Keeping them boring was deliberate: two clients written in different frameworks need an API that is easy to reason about from memory.
Route::get('restaurants', 'RestaurantController@index');
Route::get('restaurants/{id}/menu', 'MenuController@show');
Route::get('search', 'SearchController@query');
Route::middleware('auth:api')->group(function () {
Route::post('orders', 'OrderController@store');
Route::get('orders/{id}', 'OrderController@show');
});
Order creation does three things in one request: validate the cart against current menu prices (never trust the client's totals), persist the order, and queue an SMS. The SMS call goes through a thin wrapper so the gateway can be swapped without touching the controller.
public function store(Request $request)
{
$items = MenuItem::whereIn('id', $request->input('items.*.id'))->get();
$total = 0;
foreach ($request->items as $line) {
// price comes from the database, not from the request
$total += $items->find($line['id'])->price * $line['qty'];
}
$order = Order::create([
'user_id' => $request->user()->id,
'total' => $total,
'status' => 'placed',
]);
Sms::send($order->user->phone, "Order #{$order->id} placed.");
return response()->json($order, 201);
}
On the website, search is a short piece of jQuery. It debounces keystrokes, calls /api/search and rebuilds the results list. There is nothing clever here, but it is the feature customers used most, so it had to feel instant.
var timer;
$('#q').on('keyup', function () {
clearTimeout(timer);
var q = $(this).val();
timer = setTimeout(function () {
$.getJSON('/api/search', { q: q }, function (rows) {
var html = rows.map(function (r) {
return '<li data-id="' + r.id + '">' + r.name + '</li>';
}).join('');
$('#results').html(html);
});
}, 250);
});
The Ionic app wraps the same endpoints in an AngularJS service. Having one service file that mirrors the API routes meant that when I changed a route in Laravel I knew exactly the one place in the app to update.
What I learned
- The API is the product. The website and the app were replaceable; the data model and the endpoints were not. I spent most of my design time on the backend and it paid off every time a client wanted a UI change.
- Never trust client totals. Recomputing prices on the server from the database is a few lines and removes an entire class of bugs and abuse. I would do this on day one of any commerce project.
- Notifications are a feature, not a nice-to-have. The SMS integration was the thing the business owner noticed first. Customers stopped calling to ask where their food was.
- Hybrid apps were the right call for one person. Ionic 1 was already dated in 2019, but a single AngularJS codebase for Android and iOS was the only way I could ship a mobile app alongside a website in the same timeframe.
- Three repositories is one too many for a solo project. Splitting backend, web and app made deployment simple but coordination hard. If I did it again I would keep the web frontend inside the Laravel project and only separate the mobile app.
- Reporting gets asked for last and needed first. The admin reports were an afterthought in the brief and the most used screen after launch. The next article in this series is about exactly that.
- Payment gateways eat time. Sandbox credentials, redirect flows and callback verification took longer than the entire cart. Budget for it.
Looking at the code now, the stack is firmly of its time: Bootstrap 3, jQuery, AngularJS 1, Laravel 5. What has aged well is the shape of it. A boring REST API, server side validation, and clients that stay thin is still how I would structure a small commerce product today, just with different libraries.
Backend and admin on GitHub >
Web frontend on GitHub >
Ionic app on GitHub >