Lumen is the younger brother of Laravel. It is supposed to be faster and it is especially designed for building APIs. With this project, we aim to create a JSON API which will also provide OAuth support, mainly for using the visitor’s Google account for authentication.
Decisions, assumptions and hints
- The app might be used on shared hosting as well;
- The
lumen
version is5.8.*
; - The root of the project is
./app
– for commands and paths, we assume we have it as root; - The prefix for our app is
lao
(from “Lumen API OAuth”); nano
is a code editor andcat
is a read and write utility, both specific to unix like systems;- Please note that some commands (e.g.
composer
) are available inside the container.
Setup the base of the project
As structure, we will separate docker from the lumen source code. Depending on the project you are working on, the needs might vary. We will also add some helper files.
Some helper files are the .gitignore
files. Other such file is .editorconfig
, which is a great help for keeping the code in order, regardless of the operating system where it is edited or the code editor used:
root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [*.php] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true indent_style = space indent_size = 4 [*.sh] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false
./src
contains the lumen
source code.
./docker
contains the docker
and docker-compose
related code. This is intended to be used for development purposes, thus the configuration is specified inside the docker-compose.yml
file.
A repo with the code is available at: https://gitlab.com/cristidraghici/lumen-api-with-oauth-support.
Docker and docker-compose
Create a basic ./docker/docker-compose.yml
file:
version: '2' services: lao-lumen: container_name: lao-lumen build: context: ./lumen dockerfile: Dockerfile restart: unless-stopped ports: - "9000:80" volumes: - ./../src:/var/www:cached - ./lumen/.data/logs:/var/log/nginx:delegated links: - lao-database depends_on: - lao-database lao-database: container_name: lao-database build: context: ./database dockerfile: Dockerfile restart: unless-stopped ports: - "33066:3306" environment: - MYSQL_DATABASE=homestead - MYSQL_USER=homestead - MYSQL_PASSWORD=secret - MYSQL_ROOT_PASSWORD=secret volumes: - ./database/.data/mysql:/var/lib/mysql:delegated networks: default: external: name: "lao-network"
A special mention about the .yml content above: :delegated
and :cached
help with performance. More information here:
https://docs.docker.com/docker-for-mac/osxfs-caching/
Lumen
Due to the fact that there are special requirements for the application, we will define a ./docker/lumen/Dockerfile
for it:
FROM php:7.1-fpm-jessie RUN docker-php-ext-install pdo_mysql bcmath mbstring sockets exif \ && apt-get update \ && apt-get install zip unzip # Install PHP Composer RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \ && php composer-setup.php \ && rm composer-setup.php \ && mv composer.phar /usr/local/bin/composer RUN pecl install xdebug && \ docker-php-ext-enable xdebug RUN echo "deb http://nginx.org/packages/debian/ jessie nginx" >> /etc/apt/sources.list \ && echo "deb-src http://nginx.org/packages/debian/ jessie nginx" >> /etc/apt/sources.list \ && curl http://nginx.org/keys/nginx_signing.key | apt-key add - \ && apt-get update \ && apt-get install -y nginx \ && apt-get install -y libpng-dev libjpeg-dev libwebp-dev \ && apt-get install -y libav-tools \ && rm -rf /var/lib/apt/lists/* # Forward request and error logs to docker log collector RUN ln -sf /dev/stdout /var/log/syslog WORKDIR /var/www/ COPY ./nginx.conf /etc/nginx/nginx.conf EXPOSE 80 COPY ./entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"]
The configuration for nginx
will be stored in ./docker/lumen/nginx.conf
:
user root; worker_processes 1; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; client_body_buffer_size 10M; client_header_buffer_size 10K; client_max_body_size 10M; large_client_header_buffers 2 1K; sendfile on; #tcp_nopush on; keepalive_timeout 65; gzip on; upstream php { server 127.0.0.1:9000; } server { listen 80; listen [::]:80 default_server ipv6only=on; root /var/www/public/; index index.php; # Make site accessible from http://localhost/ server_name _; location / { try_files $uri $uri/ /index.php?$query_string; } # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 location ~ \.php$ { try_files $uri /index.php =404; fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_pass php; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } # deny access to .htaccess files, if Apache's document root # concurs with nginx's one location ~ /\.ht { deny all; } } }
The ./docker/lumen/entrypoint.sh
file will be run when the container starts:
#!/bin/bash PIDS=(); nginx -g "daemon off;" & PIDS+=($!); php-fpm & PIDS+=($!); FLAG='' trap ' if [ ! "$FLAG" ]; then FLAG=true ((${#PIDS[@]})) && kill "${PIDS[@]}" fi ' CHLD set -m; wait; set +m
Database
We use mariadb
as database. For the sake of consistency, the Dockerfile
is inside a dedicated folder, ./docker/database/Dockerfile
:
FROM mariadb:10.4.11-bionic
Start the project
In order to execute commands inside the container, the containers need to be started. To do that, the following commands are necessary:
cd ./docker
docker network create lao-network
docker-compose up --build -d
lumen setup
Please note that the docker-compose.yml
we defined earlier contains the default mysql settings defined by laravel/lumen
. We will setup the lumen project inside the dedicated container – this way we will avoid installing composer
on the host machine and also have a consistent version for it in all the develoment envs.
Commands to run:
docker exec -it lao-lumen bash
composer create-project --prefer-dist laravel/lumen .
cp .env.example .env
- update
.env
with a random value forAPP_KEY
, which can be generated with:./cli.sh exec date | md5
- update
.env
withhttp://localhost:9000
forAPP_URL
; - update
.env
withdatabase
(the name of the container set in thedocker-compose.yml
file) forDB_HOST
.
cli.sh
We aim to simplify our tasks as much as possible. Thus, command line helpers are always handy. In this project, the ./cli.sh
will offer the following options:
cli.sh start
cli.sh stop
cli.sh build
Will start the project, but also rebuild the containers;cli.sh exec <command>
This will execute a command on the lumen container. A quite useful value for <command> could bebash
It will also create the docker network automatically, if needed.
#!/bin/bash case "$OSTYPE" in darwin*) OS="darwin" ;; linux*) OS="linux" ;; *) echo "unknown: $OSTYPE"; exit ;; esac function usage { echo "" echo "The parameters to use with this tool are the following: " echo "" echo " start" echo " stop" echo " build" echo " exec <command> - execute command in lao-lummen" echo "" echo "e.g. ./cli.sh exec bash" echo " ./cli.sh exec composer install" echo "" } if [ $# -eq 0 ]; then usage; exit 1 fi [ ! "$(docker network list | grep lao-network)" ] && docker network create lao-network case $1 in build) (cd docker && docker-compose down && docker-compose -f ./docker-compose.yml up -d --build --force-recreate) ;; start) (cd docker && docker-compose -f ./docker-compose.yml up -d) ;; stop) (cd docker && docker-compose down) ;; exec) shift; docker exec -it lao-lumen $@; ;; *) usage; ;; esac
Working on the API
With the basic setup done, the following steps will turn the basic lumen project into a reliable JSON api.
We aim to create an API following the guidelines found at:
https://jsonapi.org/
A note on debugging
As any respectable framework / microframework, Lumen has very good error logging using Monolog. You will find detail on errors by checking the folder: ./src/storage/logs/
.
Always return a JSON
Edit ./src/app/Exceptios/Handler.php
and replace the render()
function with:
public function render($request, Exception $e) { $parentRender = parent::render($request, $e); if ($parentRender instanceof JsonResponse) { return $parentRender; } return new JsonResponse([ 'errors' => [ [ 'status' => $parentRender->getStatusCode(), 'title' => $e instanceof HttpException ? $e->getMessage() : 'Server Error', ] ], 'status' => false ], $parentRender->status()); }
At the top of the file, use Illuminate\Http\JsonResponse;
is needed. Otherwise, you need to replace every occurrence of JsonResponse
with \Illuminate\Http\JsonResponse
.
Also, the main route should return a JSON. As a general idea, you should return as little information as possible about the package versions you are using in your application or information about your server, to prevent unnecessary exposure. But for now, just edit the main route in ./src/app/routes/web.php
to:
$router->get('/', function () use ($router) { return [ 'data' => $router->app->version(), 'status' => true ]; });
More here:
https://stackoverflow.com/questions/37296654/laravel-lumen-ensure-json-response
Add lumen-passport
Now, connect to the container and install lumen-passport
:
./cli.sh exec composer require dusterio/lumen-passport
Create a migration for the users table
Create a users migration, using our cli tool (./cli.sh exec php artisan make:migration create_users_table
) with the following content:
Schema::create('users', function (Blueprint $table) { $table->bigIncrements('id'); $table->string('name'); $table->string('email'); $table->string('password'); $table->timestamp('last_logged_in')->nullable(); $table->timestamps(); });
Create ./src/app/Http/Controllers/UsersController.php
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use App\User; use Validator; class UsersController extends Controller { public function register (Request $request) { $validator = Validator::make($request->all(), [ 'name' => 'required', 'email' => 'required|email', 'password' => 'required' ]); if ($validator->fails()) { return response([ 'message' => 'Validation errors', 'errors' => $validator->errors(), 'status' => false ], 422); } $input = $request->all(); $input['password'] = Hash::make($input['password']); $user = User::create($input); $data['token'] = $user->createToken('app')->accessToken; $data['name'] = $user->name; $data['email'] = $user->email; return response([ 'data' => $data, 'message' => 'Account created successfully!', 'status' => true ]); } }
Alter ./src/app/User.php
<?php namespace App; use Illuminate\Auth\Authenticatable; use Laravel\Lumen\Auth\Authorizable; use Illuminate\Database\Eloquent\Model; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; use Laravel\Passport\HasApiTokens; class User extends Model implements AuthenticatableContract, AuthorizableContract { use HasApiTokens, Authenticatable, Authorizable; /** * The attributes that are mass assignable. * * @var array */ protected $fillable = [ 'name', 'email', 'password', ]; /** * The attributes excluded from the model's JSON form. * * @var array */ protected $hidden = [ 'password' ]; }
Create ./src/app/config/auth.php
<?php return [ 'defaults' => [ 'guard' => 'api', 'passwords' => 'users', ], 'guards' => [ 'api' => [ 'driver' => 'passport', 'provider' => 'users', ], ], 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => \App\User::class ] ] ];
Alter ./src/bootstrap/app.php
Uncomment the following two lines:
- $app->withFacades();
- $app->withEloquent();
And before $app->router->group([...
add:
// Load auth config files $app->configure('auth'); // Enable auth middleware (shipped with Lumen) $app->routeMiddleware([ 'auth' => App\Http\Middleware\Authenticate::class, 'client' => CheckClientCredentials::class, ]); // Finally register two service providers - original one and Lumen adapter $app->register(Laravel\Passport\PassportServiceProvider::class); $app->register(Dusterio\LumenPassport\PassportServiceProvider::class); // Register the routes \Dusterio\LumenPassport\LumenPassport::routes($app, ['prefix' => 'v1/oauth']);
Alter ./src/routes/web.php
<?php /* |-------------------------------------------------------------------------- | Application Routes |-------------------------------------------------------------------------- | | Here is where you can register all of the routes for an application. | It is a breeze. Simply tell Lumen the URIs it should respond to | and give it the Closure to call when that URI is requested. | */ $router->get('/', function () use ($router) { return 'Hello, there! :)'; }); $router->get('/api', function () use ($router) { return [ 'data' => $router->app->version(), 'status' => true ]; }); $router->post('/api/v1/register','UsersController@register');
Migrations and passport
The following commands will be executed inside the container and will handle running the migrations for the database structure and also install passport:
./cli.sh exec php artisan migrate
./cli.sh exec php artisan passport:install
./cli.sh exec php artisan passport:client --personal
More information here:
- https://github.com/dusterio/lumen-passport
- https://medium.com/@yomiomotoso/integrating-laravel-passport-in-your-lumen-project-with-example-1c2b8719c30
- https://medium.com/@poweredlocal/developing-a-simple-api-gateway-in-php-and-lumen-f84756cce043
- https://auth0.com/blog/developing-restful-apis-with-lumen/
- https://medium.com/the-andela-way/setting-up-oauth-in-lumen-using-laravel-passport-2de9d007e0b0
- https://www.digitalocean.com/community/tutorials/an-introduction-to-oauth-2
- https://digitalleaves.com/social-login-for-your-rest-api-using-oauth-2-i/