Microservices have become a popular architectural pattern for building large-scale web applications. Scalability, robustness, and adaptability are just a few of the numerous advantages that have made microservices architecture more and more well-liked in recent years. By breaking up monolithic applications into smaller, independent services, developers can build and deploy software efficiently. In this article, we’ll show you how to build and test PHP microservices.
Prerequisites
- PHP 8.1 or higher with the PDO extension and PDO MySQL extension installed and enabled
- Familiarity with PHP and Laravel
- Composer
- Docker
- MySQL and the MySQL command-line client (or an alternative database tool, such as DataGrip)
Creating the Microservice
In this tutorial, we’ll build two microservices: a Product service and an Order service, one of which depends on the other. The product service provides information about the product to the order service. First, we create a microserve folder. This is our parent directory, which will house our API gateway and microservices.
mkdir microserve
cd microserve
For this tutorial, we will make use of Laravel. Run the command below to create the Product service, navigate to the application directory and get it running.
composer create-project laravel/laravel product_service
cd product_service
If errors are encountered while installing Laravel, visit the documentation.
Creating the database
In the terminal, run the command below to connect to the MySQL database using MySQL’s command-line client, substituting your username for the placeholder ([username]
) and entering your password when prompted.
mysql -u [username] -p
Then, create a database called product_service
by running the command below.
CREATE DATABASE product_service;
With the database created, exit MySQL’s command-line client by pressing CTRL+D or CMD+D, as the case may be.
Configure your application
In the .env
file, replace the values of your MySQL credentials and database name with the default value. To do that, add the following variables to the .env
file:
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=product_service
DB_USERNAME=DB_USERNAME
DB_PASSWORD=DB_PASSWORD
Next, we’ll create Model, Migration and Controller classes by executing the command below. The Model class creates a link to interact with the underlying database table. The Migration class automatically generates tables in the linked database. Finally, the Controller class interacts with incoming requests from the browser.
php artisan make:model Product -m -c
Navigate to the newly-created app/Models/Product.php
model file in your preferred code editor and replace the code with the following:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use HasFactory;
protected $table = 'products';
protected $guarded = [
'id'
];
}
The code above defines the column in the database table that shouldn’t be assigned. To learn more, please read this.
Next, replace the following code in the database/migrations/*_create_products_table.php
migration file, where * is the timestamp when the file was created:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->integer('price');
$table->longText('description');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('products');
}
};
The code above creates a products
table with columns of id
,name
, price
and description
.
Note: The other migration files can be removed (optionally) because they aren’t necessary for the application.
Setup controller files
In the app/Http/Controller
there is a file called ProductController.php
; paste the code shown below into it.
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductController extends Controller
{
/**
* Display a listing of the Product.
*
*/
public function index()
{
$products = Product::all();
return response()->json($products);
}
/**
* Store a newly created Product in storage.
*/
public function store(Request $request)
{
$product = new Product;
$product->name = $request->name;
$product->price = $request->price;
$product->description = $request->description;
$product->save();
$response = [
'code' => '200',
'message' => 'New Product created.',
];
return response()->json($response);
}
/**
* Display the specified Product.
*/
public function show(Product $product)
{
$product = Product::find($product);
return response()->json($product);
}
/**
* Update the specified Product in storage.
*/
public function update(Request $request, Product $product)
{
$product->update([
'name' => $request->input('name'),
'price' => $request->input('price'),
'description' => $request->input('description'),
'updated_at' => now()
]);
$response = [
'code' => '200',
'message' => 'Product updated.',
];
return response()->json($response);
}
/**
* Remove the specified Product from storage.
*/
public function destroy(Product $product)
{
$product = Product::where('id', $product->id)->delete();
$response = [
'code' => '200',
'message' => 'product removed successfully.',
];
return response()->json($response);
}
}
The code creates a series of methods that performs different tasks, as specified below.
Method | Function |
---|---|
index | Lists out all existing products in the database |
store | Stores new products into the database |
show | Displays a specific product in the database |
update | Update a specific product in the database |
destroy | Delete a specific product in the database |
Setup routes files
A route has been predefined in ‘routes/api.php’ but we have no use for it, so we will remove it and replace it with the following code:
use App\Http\Controllers\ProductController;
Route::prefix('/v1')->group(function () {
Route::get('/items', [ProductController::class, 'index']);
Route::post('/items', [ProductController::class, 'store']);
Route::get('/items/{product:id}', [ProductController::class, 'show']);
Route::put('/items/{product:id}', [ProductController::class, 'update']);
Route::delete('/items/{product:id}', [ProductController::class, 'destroy']);
});
The code maps routes to their respective Controller’s method.
Writing Tests
With the Microservice developed, it is time to put it to the test. To achieve this, we will write a series of feature tests that will check each of our application’s functions. We will create the test by running the command shown below:
php artisan make:test ProductTest
The command will create a new file named ProductTest.php
in the tests/Feature
folder.
We will be writing tests for each unit of our application. We are writing these tests to ensure that a product can be:
- added
- deleted
- updated
- listed
Update the ProductTest.php
with the following code.
<?php
namespace Tests\Feature;
use App\Models\Product;
use Tests\TestCase;
class ProductTest extends TestCase
{
/**
* A feature test to get all product data
* @return void
*/
public function test_to_get_all_products_data(): void
{
$response = $this->get('/api/v1/items')
->assertStatus(200)
->assertJsonStructure(
[
'*' => [
"id",
"name",
"price",
"description",
],
]
);
}
/**
* A feature test to add a new product
*
* @return void
*/
public function test_for_add_product(): void
{
$product = Product::create([
'name' => fake()->word(),
'price' => fake()->numberBetween(0, 1000),
'description' => fake()->sentence(12),
]);;
$payload = [
"name" => $product->name,
"price" => $product->price,
'description' => $product->description,
];
$this->json('POST', 'api/v1/items', $payload)
->assertStatus(200)
->assertJson([
'code' => '200',
'message' => 'New Product created.',
]);
}
/**
* A feature test to get active product data based on product ID
*
* @return void
*/
public function test_get_product_by_id(): void
{
$product_id = Product::get()->random()->id;
$response = $this->get('/api/v1/items/' . $product_id)
->assertStatus(200)
->assertJsonStructure(
[
'*' => [
"id",
"name",
"price",
"description",
],
]
);
}
/**
* A feature test to update product based on product ID
*
* @return void
*/
public function test_for_update_product(): void
{
$payload = [
"name" => fake()->word(),
'price' => fake()->numberBetween(0, 1000),
'description' => fake()->sentence(12),
'updated_at' => fake()->date('Y-m-d', 'now'),
];
$product_id = Product::get()->random()->id;
$this->json('PUT', 'api/v1/items/' . $product_id, $payload)
->assertStatus(200)
->assertJson([
'code' => '200',
'message' => 'Product updated.',
]);
}
/**
* A feature test to delete hotel review data
*
* @return void
*/
public function test_for_delete_product(): void
{
$product_id = Product::get()->random()->id;
$this->json('DELETE', 'api/v1/items/' . $product_id)
->assertStatus(200)
->assertJson([
'code' => '200',
'message' => 'product removed successfully.',
]);
}
}
So far, we’ve built a single microservice and written a few tests to confirm that each code block works well, but first we have to run the migrations to ensure that our table is inserted into the database. The following code runs the test:
php artisan migrate
php artisan test
the test output should be similar to:
PASS Tests\Unit\ExampleTest
✓ that true is true 0.52s
PASS Tests\Feature\ProductTest
✓ to get all products data 2.99s
✓ for add product 1.43s
✓ get product by id 0.86s
✓ for update product 0.13s
✓ for delete product 0.16s
Tests: 7 passed (32 assertions)
Duration: 17.48s
Now let’s run the PHP development server .
php artisan serve
Communication between microservices
There are different methods of communication between microservices. In this walkthrough we’ll be using an Asynchronous protocol (API gateway). Application programming interface (API) gateways perform the function of reverse proxies by accepting all client requests for the services and applications that they are designed to access. There are tons of open-source gateway software, and in this tutorial we will make use of Tyk.
To install Tyk, clone the repo inside the microserve
directory then navigate to the directory in a new command-line tab.
git clone https://github.com/TykTechnologies/tyk-gateway-docker
cd tyk-gateway-docker
next run the deploy Tyk to docker
docker-compose up -d
If you run into file sharing issues, add the directory to docker > resource > file sharing
in Docker settings.
Now that Tyk is running, we are ready to “map” our API to the API gateway. On the cloned Tyk gateway’s directory there is an apps
directory. That is where we place the API definitions that tell Tyk how to protect and reverse proxy our APIs. For this, we can make use of an HTTP client (Postman, Hoppscotch) or via curl command line, as shown below.
curl -XPOST -H 'X-Tyk-Authorization: foo' -H "Content-type: application/json" -d '{
"name": "Product_service",
"api_id": "2",
"org_id": "default",
"definition": {
"location": "header",
"key": "version"
},
"use_keyless": true,
"version_data": {
"not_versioned": true,
"versions": {
"Default": {
"name": "Default"
}
}
},
"custom_middleware": {
"pre": [
{
"name": "testJSVMData",
"path": "./middleware/injectHeader.js",
"require_session": false,
"raw_body_only": false
}
]
},
"driver": "otto",
"proxy": {
"listen_path": "/",
"target_url": "http://host.docker.internal:8000/api/v1/",
"strip_listen_path": true
}
}' 'http://localhost:8080/tyk/apis/'
The takeaway from the previous curl request is the proxy object where we define the URL of our app and the path to access it on the gateway, in this case /
. You will notice that instead of the url http://localhost:8000, which is our application URL, we made use of http://host.docker.internal:8000/
. This is because in order for Docker to access our local machine’s network we have to indicate that we want it to do so. The output of the command should be:
{
"key": "2",
"status": "ok",
"action": "added"
}
Next we reload the gateway using
curl -H "x-tyk-authorization: foo" -s http://localhost:8080/tyk/reload/group
The value of x-tyk-authorization
can be changed in the docker-compose.yml
file under the TYK_GW_SECRET
variable.
Our application can currently be accessed at “http://localhost:8080/.” Only the API route of our application is available through this route, hence the URL to browse every product is http://localhost:8080/items. To read more about the functionality of Tyk gateway, check out their docs.
Now that our Product service is linked to the gateway, we are going to create our Order service, which won’t be linked to the gateway but will still get synchronized data from the gateway via an API request.
composer create-project laravel/laravel order_service
cd order_service
As was the case when building the Product service, this command establishes the Order service and navigates to the folder.
The next step is to run the following command in the terminal, inputting your username for the placeholder (‘[username]’), and providing your password when prompted.
mysql -u [username] -p
Then, create a database called order_service
by running the command below.
CREATE DATABASE order_service;
With the database created, exit MySQL’s command-line client by pressing CTRL+D or CMD+D, as the case may be.
In the .env
file, replace the values of your MySQL credentials and database name with the default value. To do that, add the following variables to the .env
file:
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=order_service
DB_USERNAME=DB_USERNAME
DB_PASSWORD=DB_PASSWORD
Next, we’ll run the command below to build the classes Model, Migration, and Controller.
php artisan make:model Order -m -c
Navigate to the newly created app/Models/Order.php
model file in your preferred code editor and replace the code with the following code:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
use HasFactory;
protected $table = 'orders';
protected $guarded = [
'id'
];
}
Next replace the following code in the database/migrations/*_create_orders_table.php
migration file where *
is the timestamp the file was created:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('product_id');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('orders');
}
};
The code above creates a products
table with columns of id
and product_id
.
Setting up controller files
The controller class is only going to have two methods since it is only meant to demonstrate communications between both services. It will have the store
method to insert new orders into the database and the show
method to view data already stored in the database.
The show
method first locates the order using Route Model Binding from the request, after which it sends an HTTP request to the API gateway to retrieve the values of the product connected with the order and returns the result. The returned value should appear as shown below.
[
{
"id": 1,
"product": {
"id": "2",
"name": "ipsa",
"price": 631,
"description": ""
},
"created_at": "2023-05-15T00:28:10.000000Z",
"updated_at": "2023-05-15T00:28:10.000000Z"
}
]
Now, paste the following code into the OrderController.php
file, which is located in the app/Http/Controller
directory.
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class OrderController extends Controller
{
/**
* Store a newly created Order in storage. */
public function store(Request $request)
{
$order = new Order;
$order->product_id = $request->product_id;
$order->save();
$response = [
'code' => '200',
'message' => 'New Order created.',
];
return response()->json($response);
}
/**
* Display the specified Order. */
public function show(Order $order)
{
$product = Http::get('http://localhost:8080/items/' . $order->product_id)->json();
$response =[[
'id' => $order->id,
'product' => [
'id' => $order->product_id,
'name' => $product[0]['name'],
'price' => $product[0]['price'],
'description' => $product[0]['description']
]
,
'created_at' => $order->created_at,
'updated_at' => $order->updated_at,
] ];
return $response;
}
}
Setup Routes Files
A route has been predefined in ‘routes/api.php’ but we have no use for it, so we remove it and replace it with the following code:
use App\Http\Controllers\OrderController;
Route::prefix('/v1')->group(function () {
Route::post('/orders', [OrderController::class, 'store']);
Route::get('/order/{order}', [OrderController::class, 'show']);
});
Writing Tests
php artisan make:test OrderTest
The command will create a new file named OrderTest.php
in the tests/Feature
folder.
We will be writing tests for each unit of our application. We have written tests to ensure that a product can be added and listed.
Update the OrderTest.php
with the following code.
<?php
namespace Tests\Feature;
use App\Models\Order;
use Tests\TestCase;
class OrderTest extends TestCase
{
/**
* A feature test to add a new order * * @return void
*/ public function test_for_add_order(): void
{
$order = Order::create([
'product_id' => 1,
]);
$payload = [
"product_id" => $order->product_id,
];
$this->json('POST', 'api/v1/orders', $payload)
->assertStatus(200)
->assertJson([
'code' => '200',
'message' => 'New Order created.',
]);
}
/**
* A feature test to get active order data based on order ID * * @return void
*/ public function test_get_order_by_id(): void
{
$order_id = Order::all()->random()->getAttribute('id');
$this->get('/api/v1/order/' . $order_id)
->assertStatus(200)
->assertJsonStructure(
[
'*' => [
'id',
'product' => [
'id',
'name',
'price',
'description'
]
]
]
);
}
}
After writing the test to check that the application functions properly, it is now time to run it, but not before we run the migrations to make sure our table is inserted into the database. The test is run via the following code.
php artisan migrate
php artisan test
the test output should be similar to:
PASS Tests\Unit\ExampleTest
✓ that true is true 0.39s
PASS Tests\Feature\ExampleTest
✓ the application returns a successful response 1.32s
PASS Tests\Feature\OrderTest
✓ for add order 1.26s
✓ get order by id 0.54s
Tests: 4 passed (12 assertions)
Duration: 7.59s
How does Semaphore fit into this application? Semaphore is a continuous integration and deployment (CI/CD) platform that enables automated application testing. Here is a guide for integrating Semaphore into your application.
Semaphore is a very powerful tool to streamline your development process and deploy high-quality microservices to production. With Semaphore, you can automate your build, test, and deployment pipelines, so you can focus on writing code. Everything that this tutorial covered can be performed within Semaphore.
Conclusion
In this article, we built and tested our own PHP microservices. Creating another microservice just requires the same process of development and integration with an API gateway only this time to a different route on the gateway. Happy Building!