Yurly is a lightweight web MVC routing library for PHP 7. It's easy to get started, requires almost zero configuration, and can run within existing projects without a major rewrite.
It also supports a multi-site implementation right out of the box.
- Installation
- Basic Routing
- Route Parameters
- Accepting Multiple Request Types
- Middleware
- Custom Request/Response Classes
- Dependency Injected Parameters
- Custom Route Resolvers
- Multi-site Setup
- Using
ymake
Helper - Unit Testing
In composer.json:
{
"require": {
"shaggy8871/yurly": "^2.0"
},
"require-dev": {
"phpunit/phpunit": "^8"
},
"autoload": {
"psr-4": {
"Myapp\\": "./src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "./tests/"
}
}
}
Replace Myapp
with the name of your project.
Then run:
composer install
Once composer dependencies are installed, run the following to create a basic project file structure, along with your first set of controllers and tests:
vendor/bin/ymake project
If all works as planned, you should see output as follows:
Creating project structure:
âś… Created ./src
âś… Created ./src/Controllers
âś… Created ./src/Models
âś… Created ./src/Views
âś… Created ./src/Views/cache
âś… Created ./src/Views/base.html.twig
âś… Created ./tests
âś… Created ./tests/Bootstrap.php
Creating Index controller:
âś… Created ./src/Controllers/Index.php
âś… Created ./src/Models/Index.php
âś… Created ./src/Views/Index
âś… Created ./src/Views/Index/default.html.twig
Creating test Index controller:
âś… Created ./tests/Controllers/IndexTest.php
Creating document root:
âś… Created public
âś… Created public/index.php
To test the website, run the following:
php -S localhost:8000 -t public/
Then open http://localhost:8000/ in your browser.
To run unit tests:
vendor/bin/phpunit
By default, routes are determined based on the controller class name and method name.
URL | Routes To | Notes |
---|---|---|
/ |
Index/routeDefault |
|
/about |
About/routeDefault or Index/routeAbout |
Yurly will try both in order |
/about/our-story |
About/routeOur_Story |
"-" is auto-converted to "_" |
Controllers must extend the Yurly\Core\Controller
class, and must contain at least one method name prefixed with the word route
.
Request
andResponse
classes can be injected into any route method. The firstResponse
class found will be used to render any value returned from theroute*
method.
Example src/Controllers/Index.php file:
<?php
namespace Myapp\Controllers;
use Yurly\Core\Controller;
use Yurly\Inject\Request\Get;
use Yurly\Inject\Response\{Twig, Json};
class Index extends Controller
{
/**
* This is the home page
*/
public function routeDefault(Get $request, Twig $response): array
{
return [
'title' => 'Welcome to Yurly',
'content' => 'You\'re on the home page. You can customize this view in Myapp/Views/Index/default.html.twig.'
];
}
/**
* This is an example about us page
*/
public function routeAbout(Get $request, Twig $response): array
{
return [
'title' => 'About Us',
'content' => 'You can customize this page in <Yourapp>/Views/Index/about.html.twig.'
];
}
/**
* This is an example route with JSON response
*/
public function routeJson(Get $request, Json $response): array
{
return [
'title' => 'JSON',
'content' => 'This will be output in JSON format',
'params' => $request->toArray(), // be aware - unsanitised!
];
}
}
Route parameters can be specified using a @canonical
docblock statement above the route.
<?php
namespace Myapp\Controllers;
use Yurly\Core\Controller;
use Yurly\Inject\Request\RouteParams;
use Yurly\Inject\Response\Json;
class Example extends Controller
{
/**
* @canonical /example/:requiredParam(/:optionalParam)
*/
public function routeDefault(RouteParams $request, Json $response)
{
return $request->toArray();
/**
* You can also access route parameters via:
* - $request->requiredParam
* - $request->optionalParam
*/
}
}
In the example above, calling /example/hello/world will return a JSON response as follows:
{
"requiredParam":"hello",
"optionalParam":"world"
}
The generic Request
class has helper functions that can be used to extract multiple request types.
<?php
namespace Myapp\Controllers;
use Yurly\Core\Controller;
use Yurly\Inject\Request\Request;
use Yurly\Inject\Response\Json;
class Example extends Controller
{
public function routeDefault(Request $request, Json $response)
{
// GET parameters
$getParams = $request->get();
// POST parameters
$postParameters = $request->post();
/**
* You can access get/post parameters via:
* - $getParams->paramName
* - $postParams->paramName
*/
}
}
Yurly exposes options for middleware code to run before and after a route is called.
For each route, adding a @before
docblock above the method declaration will run the designated methods before calling the route. This may, for instance, be used to point the user to an alternative route, or to look up additional metadata before the route code runs. Middleware may be specified as either the name of the method to call, or if outside the controller, in the form Controller::method
. Multiple methods may be specified as a comma-delimited list and will be run in the order supplied.
To alter the route that gets rendered, middleware should return an alternative route as a string in the form Controller::routeMethod
.
To run code before all routes in a controller are called, add a beforeAllRoutes()
method to the controller. This will be run before all @before
docblock methods are called.
The @after
docblock will call the designated class methods after the route has run. Each middleware method will receive a copy of the response, and may alter it before it renders to the browser. Multiple methods may be specified as a comma-delimited list and will be run in the order supplied.
To run middleware after all routes in a controller are called, add an afterAllRoutes()
method to the controller. This code will be run before all @after
docblock methods are called.
src/Myapp/Middleware/Auth.php:
<?php
namespace Myapp\Middleware;
use Yurly\Core\{Url, Context};
trait Auth
{
public function isLoggedIn(Url $url, Context $context): ?string
{
$caller = $context->getCaller();
$annotations = $caller->getAnnotations();
// The @role docblock isn't required, it's a suggestion
$roles = [];
if (isset($annotations['role'])) {
$roles = explode(', ', $annotations['role']);
}
if (!$this->authenticate($roles)) {
return 'User::routeLogout';
}
return null;
}
private function authenticate(array $roles): bool
{
// Add your auth code here
}
}
src/Myapp/Controllers/Admin.php:
<?php
namespace Myapp\Controllers;
use Yurly\Core\Controller;
use Yurly\Inject\Request\Get;
use Yurly\Inject\Response\Twig;
use Myapp\Middleware\Auth;
class Admin extends Controller
{
use Auth;
/**
* @before isLoggedIn
* @role admin
*/
public function routeDefault(Get $request, Twig $response): array
{
return [
'message' => 'Welcome!'
];
}
}
Middleware methods are able to use a class called MiddlewareState
to check the response from the previous middleware method, and to stop the router from calling subsequent middleware methods if need be.
Calling
stop()
does not prevent the route from being called. It only prevents subsequent middleware methods from being called.
src/Myapp/Controllers/Admin.php:
<?php
namespace Myapp\Controllers;
use Yurly\Core\Controller;
use Yurly\Inject\Response\Twig;
use Yurly\Middleware\MiddlewareState;
use Myapp\Models\User;
use Myapp\Models\AdminPermissions;
class Admin extends Controller
{
private $user;
private $permissions;
/**
* @before isLoggedIn, hasPermission
*/
public function routeDefault(Twig $response): array
{
return [
'user' => $this->user,
'permissions' => $this->permissions,
];
}
public function isLoggedIn(MiddlewareState $state): ?string
{
$this->user = User::getLoggedIn();
if (!($this->user instanceof User)) {
// Don't call hasPermission, go straight to login route
$state->stop();
return 'User::routeLogin';
}
return null;
}
public function hasPermission(MiddlewareState $state): ?string
{
$this->permissions = AdminPermissions::getPermissions($this->user);
if (empty($this->permissions)) {
// Go to access denied route
$state->stop();
return 'User::routeAccessDenied';
}
return null;
}
}
Custom Request and Response classes may be created to supply additional functionality not already supplied by the built-in classes. Examples include additional input sources, improved input sanitisation and output data mapping.
src/Myapp/Models/User.php:
<?php
namespace Myapp\Models;
class User
{
public $fname;
public $lname;
//... more props
// Example find method
public static function findById(int $id): ?self
{
if ($id == 1) {
$instance = new static();
$instance->fname = 'First name';
$instance->lname = 'Last name';
return $instance;
}
return null;
}
}
src/Myapp/Inject/Request/UserFinder.php:
<?php
namespace Myapp\Inject\Request;
use Yurly\Inject\Request\RouteParams;
use Myapp\Models\User;
class UserFinder extends RouteParams
{
public function find(): ?User
{
$userId = filter_var($this->id, FILTER_VALIDATE_INT);
if ($userId) {
return User::findById($userId);
}
return null;
}
}
src/Myapp/Inject/Response/UserJsonDataMapper.php:
<?php
namespace Myapp\Inject\Response;
use Yurly\Inject\Response\Json;
use Myapp\Models\User;
class UserJsonDataMapper extends Json
{
/**
* Render parameters with data mapping
*/
public function render($params = null): void
{
if ($params instanceof User) {
parent::render([
'first_name' => $params->fname,
'last_name' => $params->lname,
]);
return;
}
parent::render([
'error' => 'User Not Found'
]);
}
}
src/Myapp/Controllers/User.php:
<?php
namespace Myapp\Controllers;
use Yurly\Core\Controller;
use Myapp\Inject\Request\UserFinder;
use Myapp\Inject\Response\UserJsonDataMapper;
use Myapp\Models\User as UserModel;
class User extends Controller
{
/**
* @canonical /user/:id
*/
public function routeDefault(UserFinder $request, UserJsonDataMapper $response): ?UserModel
{
return $request->find();
}
}
Yurly does not include native support for dependency injection outside of Request and Response classes, but it's easy enough to add a PSR-11 compatible DI solution through composer. Here's an example using PHP-DI:
composer.json
composer require php-di/php-di
src/Myapp/Config.php:
<?php
namespace Myapp;
use Yurly\Core\Project;
use DI\Container;
class Config
{
public function __construct(Project $project)
{
$container = new Container();
$project->addContainer($container);
}
}
src/Myapp/Controllers/Index.php:
class Index extends Controller
{
/**
* Yurly\Core\Project must always be the first parameter
*/
public function __construct(Project $project, \Myapp\Models\User $user)
{
// $user is instantiated and ready
parent::__construct($project);
}
public function routeWithDI(\Myapp\Models\User $user)
{
// $user is instantiated and ready
}
}
If you have routes that don't follow the controller/method approach, it's easy to create a custom route resolver class that can handle custom routing.
Create a class called RouteResolver
at the base of your project directory, and ensure it implements RouteResolverInterface
. It must contain one method called resolve
that returns a route in the format Controller::method
. Any other return value will be ignored.
<?php
namespace Myapp;
use Yurly\Core\{Project, Url};
use Yurly\Core\Interfaces\RouteResolverInterface;
use Yurly\Core\Utils\RegExp;
class RouteResolver implements RouteResolverInterface
{
public function resolve(Project $project, Url $url)
{
$routes = [
[
'match' => new RegExp('/^\/[a-z0-9]+\/product\/[a-z0-9]+\/?$/'),
'route' => 'Products::routeSearch',
],
//... add your match/routes here
];
foreach($routes as $route) {
if ($route['match']->matches($url->requestUri)) {
return $route['route'];
}
}
}
}
In composer.json, add a psr-4
autoload for each unique namespace, for example:
{
"name": "example",
"require": {
"php": ">=7.2.0",
"shaggy8871/yurly": "^2.0",
"twig/twig": "^2.0"
},
"require-dev": {
"phpunit/phpunit": "^8"
},
"autoload": {
"psr-4": {
"Site1\\": "./src/Site1/",
"Site2\\": "./src/Site2/"
}
}
}
In public/index.php, enter a project row for each unique domain:
<?php
include_once "../vendor/autoload.php";
use Yurly\Core\{Project, Init};
$projects = [
new Project('www.site1.com', 'Site1', './src'),
new Project('www.site1.com', 'Site2', './src'),
];
$app = new Init($projects);
// Start 'em up
$app->run();
Create a Controllers
directory within both /src/Site1
and /src/Site2
and add your Index.php controller class. You may also create Models
, Views
and any other directories as may be required.
If you need to support multiple hosts for a project, you can either pass in an array of hosts, or use a RegExp
helper class as follows:
// Array of hosts per project:
$projects = [
new Project(['www.site1.com', 'dev.site1.com'], 'Site1', './src'),
new Project(['www.site2.com', 'dev.site2.com'], 'Site2', './src'),
];
or
use Yurly\Core\Utils\RegExp;
// RegExp helper class:
$projects = [
new Project(new RegExp('/^.*\.site1\.com$/'), 'Site1', './src'),
new Project(new RegExp('/^.*\.site2\.com$/'), 'Site2', './src'),
];
Yurly ships with a helper application called ymake
. You can use the helper to create a project, set of controller, model and view files, or an index.php file.
- Add an autoload namespace to composer.json, for example:
"autoload": {
"psr-4": {
"Site1\\": "./src/Site1/",
"Site2\\": "./src/Site2/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\Site1\\": "./tests/Site1/",
"Tests\\Site2\\": "./tests/Site2/"
}
}
- Run the appropriate command to generate scripts:
Command | Creates | Notes |
---|---|---|
vendor/bin/ymake project |
A full project, including an Index controller | |
vendor/bin/ymake controller |
A controller, model and view | Project must exist first |
vendor/bin/ymake index |
An index.php file | |
vendor/bin/ymake test |
A unit test class | Requires autoload-dev in composer.json |
You will be prompted for further details based on the command used.
Yurly extends PHPUnit's TestCase class with additional methods to help with testing of routes. Here's a simple example:
<?php
namespace Tests\Controllers;
use Yurly\Test\TestCase;
class ExampleTest extends TestCase
{
public function testRoute()
{
$response = $this
->setProjectNamespace('Myapp')
->setProjectPath('./src')
->setUrl('/')
->getRouteResponse();
$this->assertEquals($response, ['message' => 'Welcome!']);
}
}
If you prefer to capture the full route response output, just call the route as follows:
<?php
namespace Tests\Controllers;
use Yurly\Test\TestCase;
class ExampleTest extends TestCase
{
public function testRoute()
{
$this->expectOutputString('<h1>Welcome to Yurly!</h1>');
$response = $this
->setProjectNamespace('Myapp')
->setProjectPath('./src')
->setUrl('/')
->callRoute();
}
}
You can mock request classes in order to test your controllers with different inputs.
The class type declared in the route method cannot be changed.
<?php
namespace Tests\Controllers;
use Yurly\Test\TestCase;
use Yurly\Inject\Request\Get;
class ExampleTest extends TestCase
{
public function testRouteWithRequestMock()
{
$this
->setProjectNamespace('Myapp')
->setProjectPath('./src')
->setUrl('/');
$mockRequest = $this->getRequestMock(Get::class, function(Get $self) {
$self->setProps(['hello' => 'World']);
});
$response = $this
->getRouteResponse([
Get::class => $mockRequest
]);
$this->assertEquals($response, ['message' => 'World']);
}
}
To test generic Request
classes (where the request method is unknown in advance, or where multiple inputs are expected within a single request), use the setTypeProps
method to configure props for each request type. To set the default request method, pass requestMethod
via array as a second parameter to the $this->setUrl()
method.
$this->setUrl('/', [
'requestMethod' => 'POST'
]);
$mockRequest = $this->getRequestMock(Request::class, function(Request $self) {
$self->setTypeProps(Request::TYPE_POST, [
'var1' => 'val1',
'var2' => 'val2',
'var3' => 'val3',
]);
$self->setTypeProps(Request::TYPE_GET, [
'query' => 'test'
]);
});
You can mock the response class as well, and capture the output before it renders.
You cannot pass a mock Request class to
getRouteResponse
as it already uses one to capture the output. Instead, use thecallRouteWithMocks
method.
<?php
namespace Tests\Controllers;
use Yurly\Test\TestCase;
use Yurly\Inject\Response\Twig;
class ExampleTest extends TestCase
{
public function testRouteWithResponseMock()
{
$this
->setProjectNamespace('Myapp')
->setProjectPath('./src')
->setUrl('/');
$mockResponse = $this->getResponseMock(Twig::class, function(array $params) {
$this->assertEquals($params, ['message' => 'Welcome!']);
});
$this
->callRouteWithMocks([
Twig::class => $mockResponse
]);
$mockResponse->assertOk();
$mockResponse->assertContentType('text/html');
}
}