Workplaces for digital nomads: the API
Tags: tutorial, laravel, php, api
Published: 2022-10-31 16:03:06

Another pet project: cafés and co-working spaces in sunny Cyprus. Workplaces for digital nomads ヽ(。_°)ノ

I like to share my development process. The overall development approach is pragmatic and similar to [the previous project](https://dev.to/vladimir_mvs/pragmatic-development-2lph).

## The project's goals

There are numerous cafes, coffee houses, taverns, restaurants, and bars on the island, but not everyone is good to work for at least a couple of hours.
There are well-known Starbucks, Costa Coffee, Gloria Jeans Coffee, and so on, but there are also very cosy and totally underrated local places.
That is why it was decided:

* Classify locations based on factors relevant to remote working, such as kitchen, outlets, noise, size, occupancy, view, and so on.
* Filter locations by the parameters you've chosen.
* Display a map with relevant locations.
* Create a desktop and mobile web app.

Overall, everything was successful and the [project code](https://github.com/workplacescy) is open. Website: https://workplaces.cy/

To achieve the objectives, it was decided to build a REST API microservice on Laravel, with an admin panel on Twill and a [frontend web application on Vue](https://dev.to/vladimir_mvs/workplaces-for-digital-nomads-the-frontend-2mk3). Deploy on [Fly.io](https://fly.io/) as before.

## REST API microservice

The platform chosen is the familiar and lightweight Laravel and PHP 8.1 with promoted- and readonly- properties and strict typing.

The [composer.json](https://github.com/workplacescy/api/blob/develop/composer.json) and [project configuration](https://github.com/workplacescy/api/tree/develop/config) is lightened as much as possible: unused packages and classes are removed, [platform-check](https://getcomposer.org/doc/06-config.md#platform-check) is disabled, and [classmap-authoritative](https://getcomposer.org/doc/06-config.md#classmap-authoritative) is enabled.

As a result, the number of classes to be loaded decreased 4.5 times from 28247 to 6230, the vendor directory was "thinned" almost 1.5 times, and tests were slightly faster.

## Architecture

The main [Place](https://github.com/workplacescy/api/blob/develop/app/Models/Place.php) model is a typical Laravel model that extended Twill model (`A17\Twill\Models\Model`).

[Properties for filtering](https://github.com/workplacescy/api/tree/develop/app/Enums) - native PHP enum's with a few common methods from [EnumValues](https://github.com/workplacescy/api/blob/develop/app/Traits/EnumValues.php) trait to obtain values for the admin panel. These are cast in the properties of the model.

In addition, each property has a coefficient and a weight to calculate an place's rating. For example, the [availability of outlets](https://github.com/workplacescy/api/blob/develop/app/Enums/Sockets.php) is more important than [the view](https://github.com/workplacescy/api/blob/develop/app/Enums/View.php).
```php
enum Sockets: string implements PropertyEnum
{
    use EnumValues;

    case None = 'None';
    case Few = 'Few';
    case Many = 'Many';

    public const WEIGHT = 3;


    public static function default(): self
    {
        return self::Few;
    }


    /** @inheritDoc */
    public function coefficient(): int
    {
        return match ($this) {
            self::None => 1,
            self::Few => 3,
            self::Many => 5,
        };
    }
}
```
API queries are handled by single-action controllers, validated by Request including enum matching. For example, [IndexRequest](https://github.com/workplacescy/api/blob/develop/app/Http/Requests/IndexRequest.php).
```php
#[OA\Parameter(name: 'busyness', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'city', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'size', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'sockets', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'noise', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'type', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'view', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'cuisine', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'vRate', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string', format: 'float', maximum: 0, minimum: 5))]
final class IndexRequest extends FormRequest
{
    /** @return array{busyness: string, city: string, size: string, sockets: string, noise: string, type: string, view: string} */
    public function rules(): array
    {
        return [
            'busyness' => ['sometimes', 'required', new Enum(Busyness::class)],
            'city' => ['sometimes', 'required', new Enum(City::class)],
            'size' => ['sometimes', 'required', new Enum(Size::class)],
            'sockets' => ['sometimes', 'required', new Enum(Sockets::class)],
            'noise' => ['sometimes', 'required', new Enum(Noise::class)],
            'type' => ['sometimes', 'required', new Enum(Type::class)],
            'view' => ['sometimes', 'required', new Enum(View::class)],
            'cuisine' => ['sometimes', 'required', new Enum(Cuisine::class)],
            'vRate' => ['sometimes', 'required', 'float', 'numeric', 'between:0,5'],
        ];
    }
}
```
Native PHP attributes allowed OpenAPI markup to be much more compact than in DocBlocks. The resulting [openapi.yaml](https://github.com/workplacescy/api/blob/develop/storage/openapi.yaml) is created with [swagger-php](https://zircote.github.io/swagger-php/) and used to test the API.

Apart from the validators, requests are passed through [EloquentFilter](https://github.com/Tucker-Eric/EloquentFilter)-based [filters](https://github.com/workplacescy/api/blob/develop/app/ModelFilters/PlaceFilter.php) - a very expressive solution instead of a bunch of if's and when's.

Some places have photos that are transparently uploaded to AWS S3 from the admin and processed by the [Imgix](https://imgix.com/) service. There is nothing on the API side to handle the pictures.

To get detailed geodata for the place from Google Maps, [GooglePlacesService](https://github.com/workplacescy/api/blob/develop/app/Services/GooglePlacesService.php) and [alexpechkarev/google-maps](https://github.com/alexpechkarev/google-maps/) package are used. In API service all the places are added only with the name, city and properties for rating. The rest data - coordinates, business ID, address and link are obtained from Google Places API.

To calculate the rating of an place [VRateService](https://github.com/workplacescy/api/blob/develop/app/Services/VRateService.php) is used.

Both services are wrapped in their respective actions and are available through console commands and events after recording the place.

The finished data is wrapped in [PlaceResource](https://github.com/workplacescy/api/blob/develop/app/Http/Resources/PlaceResource.php) and [PlaceCollection](https://github.com/workplacescy/api/blob/develop/app/Http/Resources/PlaceCollection.php). Excessive fields are also removed there. The middleware [JsonResponse.php](https://github.com/workplacescy/api/blob/develop/app/Http/Middleware/JsonResponse.php) is used to force response in JSON format
```php
final class JsonResponse
{
    /** @param Closure(Request): (BaseJsonResponse) $next */
    public function handle(Request $request, Closure $next): BaseJsonResponse
    {
        $request->headers->set('Accept', 'application/json');

        return $next($request);
    }
}
```
## Administrative control panel

I've worked with [Twill](https://twill.io/) before, so I decided to use it for my project: an open, free system with rich features and good support. Why not? :-)

Installed via `composer requires area17/twill`, adds some migrations and communicates transparently with existing models. In some cases, you need to add service fields like `published and activity start/stop dates to them. However, the documentation describes everything in detail.

Now I recommend to try version 3-beta: it has much more options to programmatically manage the data on pages instead of separate widgets in the blade templates.

![Administrative control panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x5yjtunc9z3t3891xfik.png)

Example of a [controller](https://github.com/workplacescy/api/blob/develop/app/Http/Controllers/Twill/PlaceController.php), [repository](https://github.com/workplacescy/api/blob/develop/app/Repositories/PlaceRepository.php) and [template](https://github.com/workplacescy/api/blob/develop/resources/views/twill/places/form.blade.php).

## Database

Simple and fast SQLite ¯\_(ツ)_/¯
Hosted on a persistent volume. No configuration required.

## Tests

For the [tests](https://github.com/workplacescy/api/tree/develop/tests), [Pest](https://pestphp.com/) is used with Laravel support, parallel execution of tests, and with disabled throttling (`$this->withoutMiddleware(ThrottleRequests::class`).

The endpoints are used to check [datasets](https://github.com/workplacescy/api/tree/develop/tests/Datasets) responses and their consistency with the OpenAPI specification.

For manual check there is [Rector](https://github.com/rectorphp/rector/) with some [exceptions](https://github.com/workplacescy/api/blob/develop/rector.php).

Found one downside: `laravel/dusk` and `php-webdriver/webdriver` are nailed to Twill and require mandatory installation, although not used in my tests :-(


## Deployment

The server is hosted on the [Fly.io](https://fly.io/) platform with managed micro VM Firecracker. It never sleeps, has a good free tier, and allows you to host both static and any application server, unlike the popular Heroku. There are also different deployment and rollback strategies, health checks, and hosting geographies to choose from.

The runtime can be set up automatically with the `flyctl launch` command from the application directory or you can write your own config and Dockerfile.

I used my [Dockerfile](https://github.com/workplacescy/api/blob/develop/Dockerfile) and run API microservice in the easiest way via `php artisan serve`.

Static distribution (admin assets and robots.txt & Co) can be delegated to the Fly platform by configuring [fly.toml](https://github.com/workplacescy/api/blob/develop/fly.toml)
```
[[statics]]
guest_path = "/var/www/html/public/assets"
url_prefix = "/assets"
```

## CI/CD

It's simple here, [GitHub Action with one workflow](https://github.com/workplacescy/api/blob/develop/.github/workflows/deploy.yml) and the same `flyctl`.

## Monitoring

[Sentry](https://sentry.io/) is used for errors tracking and [Honeybadger](https://www.honeybadger.io/) is used for uptime and availability checks.

**At this point, the microservice API is live, hosted in production, and accessible to all users. MVP is complete :-)**

[API repository](https://github.com/workplacescy), website https://workplaces.cy/

I'll tell about creating the frontend with Vue 3 Composition API in the [second part](https://dev.to/vladimir_mvs/workplaces-for-digital-nomads-the-frontend-2mk3).