PHP Espresso is a small PHP Framework I created to develop runtime web servers for PHP running CLI programs and scripts. Very similar to frameworks like Express for NodeJS, Gorilla Mux for Golang, etc.
IMPORTANT NOTE: This is just a proof of concept to test the reliability of a runtime web server for PHP, its use and implementation is discouraged for production-level projects as it's an experimental framework for learning purposes.
PHP was designed to be a Single-Threaded Non-Asynchronous programming language, hence, the implementation of these type of web servers is very difficult as there will be always blocking processes for each request, hence, this server/framework is non-scalable.
- 3.1 Creating a basic web server
- 3.2 Serving a basic static HTML Page
- 3.3 Create a POST request
- 3.4 Complete Rest API CRUD example
- 3.5 Defining middlewares
- 3.6 Asynchronous programming
- PHP 8.0.0 or major
- Have PHP sockets module installed and enabled
- Composer
- Have a initted Composer project
Install PHP Espresso via Composer:
composer require edgaralexanderfr/php-espresso
Create a server.php file inside your project with the following program:
<?php
require_once 'vendor/autoload.php';
use Espresso\Http\Request;
use Espresso\Http\Response;
use Espresso\Http\Router;
use Espresso\Http\Server;
const PORT = 80;
$server = new Server();
$router = new Router();
$router->get('/', function (Request $request, Response $response) {
return $response->send([
'message' => 'Hello world!',
'code' => 200,
]);
});
$server->use($router);
$server->listen(PORT, function () use ($server) {
$server->log('Listening at port ' . PORT . '...');
});
Run the server:
php server.php # Use sudo if necessary for port 80
Visit http://localhost or execute:
curl http://localhost
And voila! π
<?php
require_once 'vendor/autoload.php';
use Espresso\Http\Request;
use Espresso\Http\Response;
use Espresso\Http\Router;
use Espresso\Http\Server;
$server = new Server();
$router = new Router();
$router->get('/php-espresso-page', function (Request $request, Response $response) {
return $response->setPayload(
<<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<title>My Web Page with PHP Espresso!</title>
</head>
<body>
<h1>My Web Page with PHP Espresso!</h1>
<p>This page was served using PHP Espresso.</p>
</body>
</html>
HTML
);
});
$server->use($router);
$server->listen(80, function () use ($server) {
$server->log('Listening at port 80...');
});
Visit http://localhost/php-espresso-page in your browser.
<?php
require_once 'vendor/autoload.php';
use Espresso\Http\Request;
use Espresso\Http\Response;
use Espresso\Http\Router;
use Espresso\Http\Server;
$server = new Server();
$router = new Router();
$router->post('/users', function (Request $request, Response $response) {
$body_json = $request->getJSON();
return $response->send([
'message' => 'User created successfully',
'code' => 201,
'user' => $body_json,
], 201);
});
$server->use($router);
$server->listen(80, function () use ($server) {
$server->log('Listening at port 80...');
});
Execute a POST request:
curl -X POST http://localhost/users -d '{"name":"Alexander The Great"}'
<?php
require_once 'vendor/autoload.php';
use Espresso\Http\Request;
use Espresso\Http\Response;
use Espresso\Http\Router;
use Espresso\Http\Server;
/** @var stdClass[] */
$users = [];
/** @var int */
$users_id = 1;
$server = new Server();
$router = new Router();
$router->get('/users', function (Request $request, Response $response) use (&$users) {
return $response->send($users);
});
$router->get('/users/:id', function (Request $request, Response $response) use (&$users) {
$id = $request->getId();
foreach ($users as $user) {
if (isset($user->{'id'}) && $user->id == $id) {
return $response->send($user);
}
}
return $response->send([
'message' => 'User not found',
'code' => 404,
], 404);
});
$router->post('/users', function (Request $request, Response $response) use (&$users, &$users_id) {
$body = $request->getJSON();
$email = $body->email ?? null;
$name = $body->name ?? null;
if (!$email || !$name) {
return $response->send([
'message' => 'Email and Name are required',
'code' => 400,
], 400);
}
$user = (object) [
'id' => $users_id++,
'email' => $email,
'name' => $name,
];
$users[] = $user;
return $response->send([
'message' => 'User created successfully',
'code' => 201,
'user' => $user,
], 201);
});
$router->patch('/users/:id', function (Request $request, Response $response) use (&$users) {
$id = $request->getId();
$body = $request->getJSON();
foreach ($users as &$user) {
if (isset($user->{'id'}) && $user->id == $id) {
$user->email = $body->email ?? $user->email;
$user->name = $body->name ?? $user->name;
return $response->send([
'message' => 'User updated successfully',
'code' => 200,
'user' => $user,
]);
}
}
return $response->send([
'message' => 'User not found',
'code' => 404,
], 404);
});
$router->delete('/users/:id', function (Request $request, Response $response) use (&$users) {
$id = $request->getId();
foreach ($users as $i => &$user) {
if (isset($user->{'id'}) && $user->id == $id) {
array_splice($users, $i, 1);
return $response->send([
'message' => 'User deleted successfully',
'code' => 200,
]);
}
}
return $response->send([
'message' => 'User not found',
'code' => 404,
], 404);
});
$server->use($router);
$server->listen(80, function () use ($server) {
$server->log('Listening at port 80...');
});
Create a couple of users:
curl -X POST http://localhost/users -d '{"email":"john.doe@example.com","name":"John Doe"}'
curl -X POST http://localhost/users -d '{"email":"jane.doe@example.com","name":"Jane Doe"}'
Retrieve all created users:
curl http://localhost/users
Retrieve user with id
2:
curl http://localhost/users/2
Update user with id
1:
curl -X PATCH http://localhost/users/1 -d '{"name":"John James Doe"}'
Delete user with id
2:
curl -X DELETE http://localhost/users/2
PHP Espresso supports global and route middlewares. You can assign as much middlewares to a single route as you want.
To do so, you can create a new middlewares.php file and add the following code:
<?php
require_once 'vendor/autoload.php';
use Espresso\Http\Request;
use Espresso\Http\Response;
use Espresso\Http\Router;
use Espresso\Http\Server;
define('AUTH_CREDENTIALS', (object) [
'user' => 'john.doe@example.com',
'pass' => '1234567890', // Please... don't...
]);
/**
* Middleware for admin authentication.
*/
function auth(Request $request, Response $response, callable $next)
{
$authorization = $request->getHeader('Authorization') ?? '';
$auth = explode(' ', $authorization);
$type = $auth[0] ?? '';
$token = $auth[1] ?? '';
$credentials = explode(':', base64_decode($token));
$user = $credentials[0] ?? null;
$pass = $credentials[1] ?? null;
if ($type != 'Bearer' || $user != AUTH_CREDENTIALS->user || $pass != AUTH_CREDENTIALS->pass) {
return $response->send([
'message' => Espresso\Http\CODES[401],
'code' => 401,
], 401);
}
$next();
}
/** @var stdClass[] */
$users = [];
/** @var int */
$users_id = 1;
$server = new Server();
$router = new Router();
// Global middleware to check service status:
$server->use(function (Request $request, Response $response, callable $next) use ($argv) {
$status = $argv[1] ?? '';
if ($status == 'service-closed') {
return $response->send([
'message' => 'Service unavailable temporary due to maintenance',
'code' => 503,
], 503);
}
$next();
});
$router->get('/users', function (Request $request, Response $response) use (&$users) {
return $response->send($users);
});
$router->post('/users', 'auth', function (Request $request, Response $response) use (&$users, &$users_id) {
$body = $request->getJSON();
$email = $body->email ?? null;
$name = $body->name ?? null;
if (!$email || !$name) {
return $response->send([
'message' => 'Email and Name are required',
'code' => 400,
], 400);
}
$user = (object) [
'id' => $users_id++,
'email' => $email,
'name' => $name,
];
$users[] = $user;
return $response->send([
'message' => 'User created successfully',
'code' => 201,
'user' => $user,
], 201);
});
$server->use($router);
$server->listen(80, function () use ($server) {
$server->log('Listening at port 80...');
});
If you run:
php middlewares.php service-closed
And do:
curl http://localhost/users
Or:
curl -X POST http://localhost/users -d '{"email":"john.doe@example.com","name":"John Doe"}'
You will get the following message:
{"message":"Service unavailable temporary due to maintenance","code":503}
If you kill the previous server with CTRL+C and then run:
php middlewares.php
You will be able to retrieve the users list now, e.g:
curl http://localhost/users
To create a new user you need to be authenticated, to do so, assign an encoded Bearer Token using base64
to a variable and then pass the Authorization Header
to curl
command:
AUTH_TOKEN=$(echo 'john.doe@example.com:1234567890' | base64)
curl -X POST http://localhost/users -d '{"email":"john.doe@example.com","name":"John Doe"}' -H "Authorization: Bearer ${AUTH_TOKEN}"
It's still possible to do asynchronous programming with PHP Espresso by creating an asynchronous server and using the async
and $next
functions and callables:
<?php
require_once 'vendor/autoload.php';
use function Espresso\Event\async;
use Espresso\Http\Request;
use Espresso\Http\Response;
use Espresso\Http\Router;
use Espresso\Http\Server;
const SMALLER_FILE_PATH = __DIR__ . '/files/smaller-file.txt';
const BIGGER_FILE_PATH = __DIR__ . '/files/bigger-file.txt';
function read_file(string $path, int $bytes, callable $callable = null): void
{
$file = fopen($path, 'r');
$file_size = filesize($path);
$content = '';
$read_bytes = 0;
async(function () use ($bytes, $callable, &$file, $file_size, &$content, &$read_bytes) {
if ($read_bytes < $file_size) {
$chunk_size = min($file_size - $read_bytes, $bytes);
$chunk = fread($file, $chunk_size);
$content .= $chunk;
$read_bytes += $chunk_size;
return false;
}
if ($callable) {
$callable($content);
}
});
}
$server = new Server();
$router = new Router();
$router->get('/read-file', function (Request $request, Response $response, callable $next) {
$size = $request->getParam('size');
$file_path = $size == 'big' ? BIGGER_FILE_PATH : SMALLER_FILE_PATH;
read_file($file_path, 8, function (string $content) use ($request, $response, $next, $size) {
$response->send([
'file_content' => $content,
'size' => $size,
]);
$next();
});
});
$server->use($router);
$server->async(true);
$server->listen(80, function () use ($server) {
$server->log('Listening at port 80...');
});
The async
function initiates an Event Looper inside of the listen
method when running in async mode by setting $server->async(true);
.
async
may return a boolean value (false) when the async call is not done yet and returns true or nothing when it's finished.
In this example, the async call inside of the read_file
function will return false as long as the requested file is not completed yet, this by reading $bytes
as a step for each chunk read through every call inside the Event Loop as an asynchronous process.
Once the whole file is read, the $callable
callback will be called, passing in the content of the file on the async call by returning nothing at the very end of the function.
If you execute:
curl 'http://localhost/read-file?size=big'\
& curl 'http://localhost/read-file?size=small'\
& wait
The smaller file request will respond earlier than the larger file request despite of being executed right at the same time.
This could be a way to implement asynchronous programs and libraries for streaming, networking, databases, files, I/O operations, etc, although it's not perfect, it would require a vast work to implement lots of PHP libraries that were designed initially to be Single-Threaded and Synchronous.
Maybe the future of PHP is promising for this purpose with the introduction of tools like Fibers
and stuff, but yet, we will see how it goes. ππ