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.

routes/api.php
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.

app/Http/Controllers/OrderController.php
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.

js/search.js
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

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 >