+ - 0:00:00
Notes for current slide
Notes for next slide

AutoRoute for PHP

No More Route Files!

Paul M. Jones

http://paul-m-jones.com/

https://github.com/pmjones/AutoRoute/

1 / 38

Overview

  • History and review of routers
  • Problems and desires
  • AutoRoute solution
  • Tradeoffs
2 / 38

The (Good|Bad) Old Days

  • URL path mapped directly to docroot file path

  • Invoke the script at that file location

  • Parameters ...

    • via query string:

      # GET http://example.com/foo/bar.php?id=1
      /var/www/html/foo/bar.php
      $_GET['id'] = '1';
    • via path info:

      # GET http://example.com/foo/bar.php/1
      /var/www/html/foo/bar.php
      $_SERVER['PATH_INFO'] = '/1';
  • All HTTP verbs mapped to same script

  • Web server itself as degenerate form of Front Controller

3 / 38

Routers and Dispatchers

  • Modern applications do not expose scripts in document root

  • Instead, functionality encapsulated in classes

  • Formal Front Controller script (index.php or front.php)

  • Router reads the URL and maps to class/method/params

    # GET http://example.com/foo/bar/1
    class Foo
    {
    public function bar(int $id) : Response
    {
    }
    }
  • Dispatcher invokes an instance method with params

  • Need a routing file: regular expressions

4 / 38

Routes File (Rails)

# generic
get /:controller/:action/:id
# ~^/([^/]+)/([^/]+)/([^/]+)$~
# specific
get /foo/bar/:id
# ~^/foo/bar/([^/]+)$~
# constraints
get /photo/archive/{:year}/{:month}, constraints: { year: /\d+/, month: /\d+/ }
# ~^/photo/archive/(\d+)/(\d+)$~
5 / 38

Routes File (PHP FastRoute)

$r->addRoute('GET', '/users', ['Users', 'index']);
// {id} must be a number (\d+)
$r->addRoute('GET', '/user/{id:\d+}', ['Users', 'show']);
// The /{title} suffix is optional
$r->addRoute('GET', '/articles/{id:\d+}[/{title}]', ['Articles', 'read']);

Others: https://packagist.org/?query=router

6 / 38

Callable Routing (PHP Slim)

$app->get(
'/hello/{name}',
function (Request $request, Response $response, array $args) {
$name = $args['name'];
$response->getBody()->write("Hello, $name");
return $response;
}
);
7 / 38

Route Grouping (PHP FastRoute)

$r->addGroup('/admin', function (RouteCollector $r) {
# GET /admin/foo
$r->addRoute('GET', '/foo', ['Admin', 'foo']);
# GET /admin/bar
$r->addRoute('GET', '/bar', ['Admin', 'bar']);
# GET /admin/baz
$r->addRoute('GET', '/baz', ['Admin', 'baz']);
});
8 / 38

Resource Routing (Rails)

resources photos

... creates:

get /photos, to: photos#index
get /photos/new, to: photos#new
post /photos, to: photos#create
get /photos/:id, to: photos#show
get /photos/:id/edit, to: photos#edit
patch /photos/:id, to: photos#update
put /photos/:id, to: photos#update
delete /photos/:id, to: photos#destroy
9 / 38

Resource Routing (PHP)

  • Laravel

    Route::resource('photos', 'PhotoController');
  • FastRoute and others

    $r->addRoute('GET', '/photos', ['Photos', 'index']);
    $r->addRoute('GET', '/photos/new', ['Photos', 'new']);
    $r->addRoute('POST', '/photos', ['Photos', 'create']);
    $r->addRoute('GET', '/photos/{id:\d+}', ['Photos', 'show']);
    $r->addRoute('GET', '/photos/{id:\d+}/edit', ['Photos', 'edit']);
    $r->addRoute('PATCH', '/photos/{id:\d+}', ['Photos', 'update']);
    $r->addRoute('PUT', '/photos/{id:\d+}', ['Photos', 'update']);
    $r->addRoute('DELETE', '/photos/{id:\d+}', ['Photos', 'destroy']);
10 / 38

Annotation Routing

class PhotoController extends AbstractController
{
/**
* @Route("/photo", name="photo_list")
*/
public function list()
{
}
/**
* @Route("/photo/{slug}", name="photo_show")
*/
public function show($slug)
{
}
}
11 / 38

Dispatching (PHP)

$route = $router->match($urlPath);
$class = $route->getController();
$method = $route->getAction();
$params = $route->getParams();
$object = new $class();
$response = $object->$method(...$params); // or $request
12 / 38

The Problem

  • Volume

    • Route files become long and difficult to manage
    • Helpers like resource photos are framework-specific
    • Even with helpers, each added route is one more to process
  • Non-DRY

    • If you add a new controller, have to add to routes file
    • If you modify a signature, have to modify the route spec
    • Duplication of information found in class/method definitions
    • True even for annotation-based systems
13 / 38

The Desire

  • Best of the (good|bad) old days: direct mapping of URL ...
  • ... but to classes instead of file paths
  • Allowance for different HTTP verbs
  • Allowance for static tail segments
  • Automatic discovery of method parameters
  • Result: no more routing files
14 / 38

The Solution

pmjones/AutoRoute

https://github.com/pmjones/AutoRoute

15 / 38

AutoRoute automatically maps HTTP requests by verb and path to PHP classes in a specified namespace.

It reflects on a specified action method within that class to determine the dynamic URL parameters.

Merely adding a class to your source code, in the specified namespace and with the specified action method name, automatically makes it available as a route.

16 / 38

Algorithm

  • AutoRoute walks the URL path segment-by-segment
  • Looks for a matching namespace as it goes
  • On a matching namespace, looks for matching verb-prefixed class
  • On a matching class, looks for matching method parameters
  • If there are remaining segments, loop over for next namespace
17 / 38

Individual Examples

GET /photos

namespace App\Http\Photos;
class GetPhotos
{
public function __invoke()
{
}
}

GET /photo/{id}

namespace App\Http\Photo;
class GetPhoto
{
public function __invoke(int $id)
{
}
}
18 / 38

Expanded Example

Under the App\Http\ PSR-4 namespace ...

Photos/
GetPhotos.php GET /photos (browse/index)
Photo/
DeletePhoto.php DELETE /photo/1 (delete)
GetPhoto.php GET /photo/1 (read)
PatchPhoto.php PATCH /photo/1 (update)
PostPhoto.php POST /photo (create)
Add/
GetPhotoAdd.php GET /photo/add (form for creating)
Edit/
GetPhotoEdit.php GET /photo/1/edit (form for updating)
19 / 38

Static Leading Segments

Given POST /photo and this class:

namespace App\Http\Photo;
class PostPhoto
{
public function __invoke()
{
}
}
20 / 38

Individual Dynamic Segments

Given GET /photo/1 and this class:

namespace App\Http\Photo;
class GetPhoto
{
public function __invoke(int $id)
{
}
}

Given PATCH /photo/1 and this class:

namespace App\Http\Photo;
class PatchPhoto
{
public function __invoke(int $id)
{
}
}
21 / 38

Static Tail Segments

(aka "single nested resource")

Given GET /photo/1/edit and this class:

namespace App\Http\Photo\Edit;
class GetPhotoEdit
{
public function __invoke(int $id)
{
}
}
22 / 38

Multiple Dynamic Segments

Given GET /photos/archive/1979/11 and this class:

namespace App\Http\Photos\Archive;
class GetPhotosArchive
{
public function __invoke(int $year, int $month)
{
}
}
23 / 38

Variadic Segments

Given GET /photos/by-tag/foo/bar/baz and this class:

namespace App\Http\Photos\ByTag;
class GetPhotosByTag
{
public function __invoke(string ...$tags)
{
}
}
24 / 38

Recognized Typehints

  • int
  • float
  • string
  • array
    • 'foo,bar,baz' => ['foo', 'bar', 'baz']
  • bool
    • '1' | 'y' | 'yes' | 't' | 'true' => true
25 / 38

Usage

26 / 38

Installation and Setup

$ composer require pmjones/auto-route ^1.0

use AutoRoute\AutoRoute;
$autoRoute = new AutoRoute(
'App\Http',
dirname(__DIR__) . '/src/App/Http/'
);
$router = $autoRoute->newRouter();
27 / 38

Routing

try {
$route = $router->route($request->method, $request->url[PHP_URL_PATH]);
} catch (\AutoRoute\InvalidNamespace $e) {
// 400 Bad Request
} catch (\AutoRoute\InvalidArgument $e) {
// 400 Bad Request
} catch (\AutoRoute\NotFound $e) {
// 404 Not Found
} catch (\AutoRoute\MethodNotAllowed $e) {
// 405 Method Not Allowed
}
28 / 38

Dispatch

// presuming a DI-based Factory that can create new action class instances:
$action = Factory::new($route->class);
// call the action instance with the method and params,
// presumably getting back an HTTP Response
$response = call_user_func($action, $route->method, ...$route->params);
29 / 38

Configuration

$autoRoute->setSuffix('Action'); // class GetPhotoEditAction
$autoRoute->setMethod('exec'); // public function exec(...)
$autoRoute->setBaseUrl('/api'); // /api/photo/1/edit
$autoRoute->setWordSeparator('_'); // foo_bar
$router = $autoRoute()->newRouter();
30 / 38

Dumping Routes

$ php bin/autoroute-dump.php App\\Http ./src/Http

POST /photo
App\Http\Photo\PostPhoto
GET /photo/add
App\Http\Photo\Add\GetPhotoAdd
DELETE /photo/{int:id}
App\Http\Photo\DeletePhoto
GET /photo/{int:id}
App\Http\Photo\GetPhoto
PATCH /photo/{int:id}
App\Http\Photo\PatchPhoto
GET /photo/{int:id}/edit
App\Http\Photo\Edit\GetPhotoEdit
GET /photos/archive[/{int:year}][/{int:month}][/{int:day}]
App\Http\Photos\Archive\GetPhotosArchive
GET /photos[/{int:page}]
App\Http\Photos\GetPhotos
31 / 38

Generating Routes

Generator instance:

$generator = $autoRoute->newGenerator();

Usage:

use App\Http\Photo\Edit\GetPhotoEdit;
$href = $generator->generate(GetPhotoEdit::CLASS, 1);
// => /photo/1/edit
32 / 38

Conclusion

33 / 38

Tradeoffs

  • Invokable (single-action) controllers

  • Must follow naming convention

    • URL segment to PHP namespace
  • URL path matching only

    • Use Request object for headers, hostname, etc.
  • No "fine-grained" validation/constraints

    • e.g., only int not \d{4}
    • validate in the Domain, not the User Interface
34 / 38

Bonus: It's Fast

Faster than FastRoute!

https://github.com/pmjones/AutoRoute-benchmark#autoroute-benchmarks

(Not that it matters.)

35 / 38

Questions? Comments?

36 / 38
38 / 38

Overview

  • History and review of routers
  • Problems and desires
  • AutoRoute solution
  • Tradeoffs
2 / 38
Paused

Help

Keyboard shortcuts

, , Pg Up, k Go to previous slide
, , Pg Dn, Space, j Go to next slide
Home Go to first slide
End Go to last slide
Number + Return Go to specific slide
b / m / f Toggle blackout / mirrored / fullscreen mode
c Clone slideshow
p Toggle presenter mode
t Restart the presentation timer
?, h Toggle this help
Esc Back to slideshow