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 at the time this article was created 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. - Another assumption is that we already have the google client setup: values for
GOOGLE_CLIENT_ID
,GOOGLE_CLIENT_SECRET
andGOOGLE_REDIRECT_URI
, later mentioned in this article. - Please note that this article has been partially updated later, thus some packages used might have been created later than the time this article was initially published. 🙂
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
Code language: JavaScript (javascript)
./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"
Code language: JavaScript (javascript)
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
<em># Install PHP Composer</em>
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/*
<em># Forward request and error logs to docker log collector</em>
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"]
Code language: PHP (php)
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;
<em>#tcp_nopush on;</em>
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;
<em># Make site accessible from http://localhost/</em>
server_name _;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
<em># pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000</em>
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;
}
<em># deny access to .htaccess files, if Apache's document root</em>
<em># concurs with nginx's one</em>
location ~ /\.ht {
deny all;
}
}
}
Code language: PHP (php)
The ./docker/lumen/entrypoint.sh
file will be run when the container starts:
<em>#!/bin/bash</em>
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
Code language: PHP (php)
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
Code language: CSS (css)
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.
<em>#!/bin/bash</em>
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
Code language: PHP (php)
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());
}
Code language: PHP (php)
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
];
});
Code language: PHP (php)
More here:
https://stackoverflow.com/questions/37296654/laravel-lumen-ensure-json-response
Enable facades and eloquent
Edit ./src/bootstrap/app.php
and uncomment the following two lines:
- $app->withFacades();
- $app->withEloquent();
Use Socialite to authenticate with Google
Socialite is a laravel package meant to be used for authenticating your application with OAuth2 against services like Google, Facebook and many others. Since lumen is a stripped of version of laravel, we can use this package in our project.
More information:
- https://laravel.com/docs/9.x/socialite
- https://www.positronx.io/laravel-socialite-oauth-login-with-twitter-example-tutorial/
- https://dev.to/siddhartha/api-authentication-via-social-networks-in-laravel-8-using-sanctum-with-socialite-36pa
- https://www.youtube.com/watch?v=FLsSEV5ulD4
Add the package to the project with the following command:
./cli.sh exec composer require laravel/socialite
For newer lumen versions you can also add flipbox/lumen-generator "9.*"
which will bring more cli
commands to the project. This is optional and is slightly against the idea of lumen itself, which is supposed to be, by design, smaller and simpler than laravel.
Add the needed environment values
Edit the .env.example
file and add the following empty configuration strings for the google authentication configuration:
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=
Of course, the above must also be added with values in the .env
file of your project.
Then, create ./src/app/config/services.php
with the following content:
<?php
return [
'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URI'),
],
];
Code language: HTML, XML (xml)
And finally, edit ./src/bootstrap/app.php
and add:
<em>// Load services config files</em>
$app->configure('services');
Code language: PHP (php)
Create the migrations
Create a users migration, using our cli tool:
<code>./cli.sh exec php artisan make:migration create_users_table</code>
Code language: HTML, XML (xml)
Then add 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();
});
Code language: PHP (php)
After the users table is created, we need to add the providers table:
<code>./cli.sh exec php artisan make:migration create_providers_table</code>
Code language: HTML, XML (xml)
For it, add the following content:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('providers', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('provider');
$table->string('provider_id');
$table->bigInteger('user_id')->unsigned();
$table->string('avatar')->nullable();
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('providers');
}
};
Code language: HTML, XML (xml)
Once the files are updated, run: ./cli.sh exec php artisan migrate
Alter ./src/app/User.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'email_verified_at' => 'datetime',
];
public function providers()
{
return $this->hasMany(Provider::class,'user_id','id');
}
}
Code language: HTML, XML (xml)
Create ./src/app/Provider.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Provider extends Model
{
use HasFactory;
protected $fillable = ['provider','provider_id','user_id','avatar'];
protected $hidden = ['created_at','updated_at'];
}
Code language: HTML, XML (xml)
Create the ./src/app/Http/Controllers/AuthController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Hash;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Http\JsonResponse;
use Laravel\Socialite\Facades\Socialite;
use App\User;
class AuthController extends Controller
{
/**
* Redirect the user to the Provider authentication page.
*
* @param $provider
* @return JsonResponse
*/
public function redirectToProvider($provider)
{
$validated = $this->validateProvider($provider);
if (!is_null($validated)) {
return $validated;
}
return Socialite::driver($provider)->stateless()->redirect();
}
/**
* Obtain the user information from Provider.
*
* @param $provider
* @return JsonResponse
*/
public function handleProviderCallback($provider)
{
$validated = $this->validateProvider($provider);
if (!is_null($validated)) {
return $validated;
}
try {
$user = Socialite::driver($provider)->stateless()->user();
} catch (ClientException $exception) {
return response()->json(['error' => 'Invalid credentials provided.'], 422);
}
$userCreated = User::firstOrCreate(
[
'email' => $user->getEmail()
],
[
'email_verified_at' => \Carbon\Carbon::now(),
'name' => $user->getName(),
'status' => true,
]
);
$userCreated->providers()->updateOrCreate(
[
'provider' => $provider,
'provider_id' => $user->getId(),
],
[
'avatar' => $user->getAvatar()
]
);
return response()->json($userCreated, 200);
}
/**
* @param $provider
* @return JsonResponse
*/
protected function validateProvider($provider)
{
if (!in_array($provider, ['google'])) {
return response()->json(['error' => 'Please login using google.'], 422);
}
}
}
Code language: HTML, XML (xml)
Create the routes
The following routes are to be added in ./src/routes/web.php
:
// oAuth routes
$router->get('/login/{provider}', 'AuthController@redirectToProvider');
$router->get('/login/{provider}/callback', 'AuthController@handleProviderCallback');
Code language: PHP (php)
You could add these routes in a special file (e.g. ./src/routes/api.php
), but the the file would need to be loaded in the project bootstrapping app.php
file.
Alter ./src/bootstrap/app.php
Add the following line:
$app->register(Laravel\Socialite\SocialiteServiceProvider::class);
Code language: PHP (php)
Manage application tokens
Laravel offers Passport and Sanctum for managing API tokens. In one phrase: the former covers most use cases and implements OAuth2, while the latter only delivers “a much simpler API authentication development experience”, to quote the docs:
https://laravel.com/docs/8.x/passport#passport-or-sanctum
Add lumen-passport
Now, connect to the container and install lumen-passport
:
<code>./cli.sh exec composer require dusterio/lumen-passport</code>
Code language: HTML, XML (xml)
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
]);
}
}
Code language: HTML, XML (xml)
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;
<em>/**</em>
<em> * The attributes that are mass assignable.</em>
<em> *</em>
<em> * @var array</em>
<em> */</em>
protected $fillable = [
'name', 'email', 'password',
];
<em>/**</em>
<em> * The attributes excluded from the model's JSON form.</em>
<em> *</em>
<em> * @var array</em>
<em> */</em>
protected $hidden = [
'password'
];
}
Code language: HTML, XML (xml)
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
]
]
];
Code language: HTML, XML (xml)
Once you have created the file, add the following line in the ./src/bootstrap/app.php
file:
<em>// Load auth config files</em>
$app->configure('auth');
Code language: PHP (php)
Alter ./src/bootstrap/app.php
Before $app->router->group([...
add:
<em>// Enable auth middleware (shipped with Lumen)</em>
$app->routeMiddleware([
'auth' => App\Http\Middleware\Authenticate::class,
'client' => CheckClientCredentials::class,
]);
<em>// Finally register two service providers - original one and Lumen adapter</em>
$app->register(Laravel\Passport\PassportServiceProvider::class);
$app->register(Dusterio\LumenPassport\PassportServiceProvider::class);
<em>// Register the routes</em>
\Dusterio\LumenPassport\LumenPassport::routes($app, ['prefix' => 'v1/oauth']);
Code language: PHP (php)
Alter ./src/routes/web.php
Add the following line:
$router->post('/register','UsersController@register');
Code language: PHP (php)
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/
And here: