6 Jul 2023 · Software Engineering

    Building and Testing PHP Microservices With Semaphore

    16 min read
    Contents

    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

    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.

    MethodFunction
    indexLists out all existing products in the database
    storeStores new products into the database
    showDisplays a specific product in the database
    updateUpdate a specific product in the database
    destroyDelete 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 ModelMigration, 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!


    Leave a Reply

    Your email address will not be published. Required fields are marked *

    Avatar
    Writen by:
    Prosper is a freelance Laravel web developer and technical writer that enjoys working on innovative projects that use open-source software. When he's not coding, he searches for the ideal startup opportunity to pursue.
    Avatar
    Reviewed by:
    I picked up most of my skills during the years I worked at IBM. Was a DBA, developer, and cloud engineer for a time. After that, I went into freelancing, where I found the passion for writing. Now, I'm a full-time writer at Semaphore.