Building Websites with Zend Expressive 3 Rob Allen, Nineteen Feet     February 2018 ~ @akrabat

A microframework with full stack components Rob Allen ~ @akrabat

µ Framework core • Router • Container • Template renderer • Error handler • Configuration Rob Allen ~ @akrabat

Ecosystem • Filtering and validation • API rendering: HAL & Problem-API • Database abstraction • Session handling • Logging • Mail • Pagination • Caching Rob Allen ~ @akrabat

Agnostic Router: FastRoute, Aura.Router or Zend Router DI Container: Aura.Di, Auryn, Pimple, Symfony DI Container or Zend-ServiceManager Template: Plates, Twig or Zend View Rob Allen ~ @akrabat

Middleware pipeline   Rob Allen ~ @akrabat

PSR-15 MiddlewareInterface namespace   Psr\Http\Server ; use   Psr\Http\Message\ResponseInterface ; use   Psr\Http\Message\ServerRequestInterface ; interface   MiddlewareInterface {      public   function   process (          ServerRequestInterface   $request ,          RequestHandlerInterface   $handler      )   :   ResponseInterface ; } Rob Allen ~ @akrabat

PSR-15 MiddlewareInterface namespace   Psr\Http\Server ; use   Psr\Http\Message\ResponseInterface ; use   Psr\Http\Message\ServerRequestInterface ; interface   MiddlewareInterface {      public   function   process (          ServerRequestInterface   $request ,          RequestHandlerInterface   $handler      )   :   ResponseInterface ; } Rob Allen ~ @akrabat

PSR-15 MiddlewareInterface namespace   Psr\Http\Server ; use   Psr\Http\Message\ResponseInterface ; use   Psr\Http\Message\ServerRequestInterface ; interface   MiddlewareInterface {      public   function   process (          ServerRequestInterface   $request ,          RequestHandlerInterface   $handler      )   :   ResponseInterface ; } Rob Allen ~ @akrabat

PSR-15 MiddlewareInterface namespace   Psr\Http\Server ; use   Psr\Http\Message\ResponseInterface ; use   Psr\Http\Message\ServerRequestInterface ; interface   MiddlewareInterface {      public   function   process (          ServerRequestInterface   $request ,          RequestHandlerInterface   $handler      )   :   ResponseInterface ; } Rob Allen ~ @akrabat

Structure of middleware class   TimerMiddleware   implements   MiddlewareInterface {      public   function   process ( $request ,   $handler )      {          $start  

  microtime ( true );          $response  

  $handler

handle ( $request );          $taken  

  microtime ( true )  

  $start ;          $response

getBody ()

write ( " < !-- Time:  $taken  --

" );          return   $response ;      } } Rob Allen ~ @akrabat

Structure of middleware class   TimerMiddleware   implements   MiddlewareInterface {      public   function   process ( $request ,   $handler )      {          $start  

  microtime ( true );          $response  

  $handler

handle ( $request );          $taken  

  microtime ( true )  

  $start ;          $response

getBody ()

write ( " < !-- Time:  $taken  --

" );          return   $response ;      } } Rob Allen ~ @akrabat

Structure of middleware class   TimerMiddleware   implements   MiddlewareInterface {      public   function   process ( $request ,   $handler )      {          $start  

  microtime ( true );          $response  

  $handler

handle ( $request );          $taken  

  microtime ( true )  

  $start ;          $response

getBody ()

write ( " < !-- Time:  $taken  --

" );          return   $response ;      } } Rob Allen ~ @akrabat

Structure of middleware class   TimerMiddleware   implements   MiddlewareInterface {      public   function   process ( $request ,   $handler )      {          $start  

  microtime ( true );          $response  

  $handler

handle ( $request );          $taken  

  microtime ( true )  

  $start ;          $response

getBody ()

write ( " < !-- Time:  $taken  --

" );          return   $response ;      } } Rob Allen ~ @akrabat

Structure of middleware class   TimerMiddleware   implements   MiddlewareInterface {      public   function   process ( $request ,   $handler )      {          $start  

  microtime ( true );          $response  

  $handler

handle ( $request );          $taken  

  microtime ( true )  

  $start ;          $response

getBody ()

write ( " < !-- Time:  $taken  --

" );          return   $response ;      } } Rob Allen ~ @akrabat

Getting started Rob Allen ~ @akrabat

Skeleton $ composer create-project zendframework/zend-expressive-skeleton new-app Rob Allen ~ @akrabat

Skeleton $ composer create-project zendframework/zend-expressive-skeleton new-app Rob Allen ~ @akrabat

Skeleton Rob Allen ~ @akrabat

Directory structure . ├── bin/                                     ├── data/ ├── config/                                  ├── public/ │   ├── autoload/                            │   ├── css/ │   │   ├── dependencies.global.php          │   ├── js/ │   │   ├── development.local.php            │   └── index.php │   │   ├── development.local.php.dist       ├── src/ │   │   ├── local.php.dist                   │   └── App/ │   │   ├── router.global.php                ├── test/ │   │   ├── templates.global.php             │   └── AppTest/ │   │   └── zend-expressive.global.php       ├── vendor/ │   ├── config.php                           ├── composer.json │   ├── container.php                        ├── composer.lock │   ├── development.config.php               ├── phpcs.xml.dist │   ├── development.config.php.dist          └── phpunit.xml.dist │   ├── pipeline.php │   └── routes.php Rob Allen ~ @akrabat

src/App directory • Each module lives in its own namespace • Contains all code for application • ConfigProvider class for initialisation • Configuration • DI registration Rob Allen ~ @akrabat

src/App directory structure └── src/     └── App/         ├── src/         │   ├── Handler/         │   │   ├── HomePageFactory.php         │   │   ├── HomePageHandler.php         │   │   └── PingHandler.php         │   └── ConfigProvider.php         ├── templates/         │   ├── app/         │   │   └── home-page.html.twig         │   ├── error/         │   │   ├── 404.html.twig         │   │   └── error.html.twig         │   └── layout/         │       └── default.html.twig         └── test/             └── AppTest/                 └── Handler/ Rob Allen ~ @akrabat

A handler (AKA: an action) namespace   App\Handler ; use   ... ; class   HomePageHandler   implements   RequestHandlerInterface {      public   function   handle (          ServerRequestInterface   $request      )   :   ResponseInterface   {          return   new   HtmlResponse ( ' < p

Hello World < /p

' );      } } Rob Allen ~ @akrabat

Let's write a web page! Rob Allen ~ @akrabat

Bitcoin conversion   Create a page that displays the current value of 1 Bitcoin in £ , $ & € Rob Allen ~ @akrabat

Create a route config/routes.php: $app

get (      '/bitcoin' ,      App\Handler\BitcoinPageHandler :: class ,      'bitcoin' ); Rob Allen ~ @akrabat

Routes have a method config/routes.php: $app

get (      '/bitcoin' ,      App\Handler\BitcoinPageHandler :: class ,      'bitcoin' ); Rob Allen ~ @akrabat

HTTP Method $app-

get() $app-

post() $app-

put() $app-

patch() $app-

delete() Multiple methods: $app-

any() $app-

route(…, …, ['GET', 'POST'], …); Rob Allen ~ @akrabat

Routes have a pattern config/routes.php: $app

get (      '/bitcoin' ,      App\Handler\BitcoinPageHandler :: class ,      'bitcoin' ); Rob Allen ~ @akrabat

FastRoute URI pattern • Literal string match $app

get ( '/hello' ,   … ); Rob Allen ~ @akrabat

FastRoute URI pattern • Literal string match $app

get ( '/hello' ,   … ); • Placeholders are wrapped in {

} $app

get ( '/hello/{name}' ,   … ); Rob Allen ~ @akrabat

FastRoute URI pattern • Literal string match $app

get ( '/hello' ,   … ); • Placeholders are wrapped in {

} $app

get ( '/hello/{name}' ,   … ); • Optional segments are wrapped with [

] $app

get ( '/news[/{year}[/{month}]]' ,   … ); Rob Allen ~ @akrabat

FastRoute URI pattern • Literal string match $app

get ( '/hello' ,   … ); • Placeholders are wrapped in {

} $app

get ( '/hello/{name}' ,   … ); • Optional segments are wrapped with [

] $app

get ( '/news[/{year}[/{month}]]' ,   … ); • Constrain placeholders via Regex $app

get ( '/news/{year:\d{4}}}' ,   … );   // exactly 4 digits Rob Allen ~ @akrabat

Routes have a name config/routes.php: $app

get (      '/bitcoin' ,      App\Handler\BitcoinPageHandler :: class ,      'bitcoin' ); Rob Allen ~ @akrabat

Url helper • Builds URLs from route names • Can be helpful to use . to group related route names Use the router :    $router

generateUri ( 'user.profile' ,   [ 'name'  

  'Rob' ]); or in the template:    {{   path ( 'user.profile' ,   { 'name' :   'Rob' })   }} Rob Allen ~ @akrabat

Routes have a handler config/routes.php: $app

get (      '/bitcoin' ,      App\Handler\BitcoinPageHandler :: class ,      'bitcoin' ); Rob Allen ~ @akrabat

Handlers • Receive a PSR-7 Request • Manage business logic operations • Must return a PSR-7 Response • Implemented as PSR-15 RequestHandler • Create using CLI tool: $ composer expressive handler:create 
  App\Handler\BitcoinPageHandler Rob Allen ~ @akrabat

Bitcoin handler class   BitcoinPageHandler   implements   RequestHandlerInterface {    public   function   handle (      ServerRequestInterface   $request    )   :   ResponseInterface   {      $data [ 'prices' ]  

  $this

btcService

getCurrentPrices ();      return   new   HtmlResponse (        $this

template

render ( 'app::bitcoin-page' ,   $data )      );    } Rob Allen ~ @akrabat

Bitcoin handler class   BitcoinPageHandler   implements   RequestHandlerInterface {    public   function   handle (      ServerRequestInterface   $request    )   :   ResponseInterface   {      $data [ 'prices' ]  

  $this

btcService

getCurrentPrices ();      return   new   HtmlResponse (        $this

template

render ( 'app::bitcoin-page' ,   $data )      );    } Rob Allen ~ @akrabat

Bitcoin handler class   BitcoinPageHandler   implements   RequestHandlerInterface {    public   function   handle (      ServerRequestInterface   $request    )   :   ResponseInterface   {      $data [ 'prices' ]  

  $this

btcService

getCurrentPrices ();      return   new   HtmlResponse (        $this

template

render ( 'app::bitcoin-page' ,   $data )      );    } Rob Allen ~ @akrabat

Bitcoin handler class   BitcoinPageHandler   implements   RequestHandlerInterface {    public   function   handle (      ServerRequestInterface   $request    )   :   ResponseInterface   {      $data [ 'prices' ]  

  $this

btcService

getCurrentPrices ();      return   new   HtmlResponse (        $this

template

render ( 'app::bitcoin-page' ,   $data )      );    } Rob Allen ~ @akrabat

Injecting dependencies class   BitcoinPageHandler   implements   RequestHandlerInterface {    protected   $template ;    protected   $btcService ;    public   function   __construct (      TemplateRendererInterface   $template ,      BitcoinService   $btcService    )   {        $this

template  

  $template ;        $this

btcService  

  $btcService ;    }    // … Rob Allen ~ @akrabat

Expressive configuration A mushed-up associative array created from: • Invoked ConfigProvider classes from libs and modules • Config files in config/autoload/ Common top level keys: • dependencies • templates • twig • filters • validators • db • cache Rob Allen ~ @akrabat

ConfigProvider namespace   App ; class   ConfigProvider {      public   function   __invoke ()   :   array      {          return   [              'dependencies'  

  $this

getDependencies (),              'templates'     

  $this

getTemplates (),          ];      } Rob Allen ~ @akrabat

ConfigProvider namespace   App ; class   ConfigProvider {      public   function   __invoke ()   :   array      {          return   [              'dependencies'  

  $this

getDependencies (),              'templates'     

  $this

getTemplates (),          ];      } Rob Allen ~ @akrabat

Dependency configuration      public   function   getDependencies ()   :   array      {          return   [              'factories'   

  [                  Handler\BitcoinPageHandler :: class  

                     Handler\BitcoinPageFactory :: class ,              ],          ];      } Rob Allen ~ @akrabat

Dependency configuration      public   function   getDependencies ()   :   array      {          return   [              'factories'   

  [                  Handler\BitcoinPageHandler :: class  

                     Handler\BitcoinPageFactory :: class ,              ],          ];      } Rob Allen ~ @akrabat

Dependency configuration      public   function   getDependencies ()   :   array      {          return   [              'factories'   

  [                  Handler\BitcoinPageHandler :: class  

                     Handler\BitcoinPageFactory :: class ,              ],          ];      } Rob Allen ~ @akrabat

Handler factory Return an instance of the handler with its injected dependencies class   BitcoinPageFactory {      public   function   __invoke ( $container )      {          return   new   BitcoinPageHandler (              $container

get ( TemplateRendererInterface :: class ),              $container

get ( BitcoinService :: class )          );      } } Rob Allen ~ @akrabat

Handler factory Return an instance of the handler with its injected dependencies class   BitcoinPageFactory {      public   function   __invoke ( $container )      {          return   new   BitcoinPageHandler (              $container

get ( TemplateRendererInterface :: class ),              $container

get ( BitcoinService :: class )          );      } } Rob Allen ~ @akrabat

Handler factory Return an instance of the handler with its injected dependencies class   BitcoinPageFactory {      public   function   __invoke ( $container )      {          return   new   BitcoinPageHandler (              $container

get ( TemplateRendererInterface :: class ),              $container

get ( BitcoinService :: class )          );      } } Rob Allen ~ @akrabat

Templates Expressive's view layer Rob Allen ~ @akrabat

Templating • Render method to convert page: $html  

  $this

template

render ( 'app::bitcoin-page' ,   $data ); • Templates are namespaced: namespace::template • Extension is resolved by the adapter: app::bitcoin-page =

 app/bitcoin-page.html.twig Rob Allen ~ @akrabat

Twig templates "Twig is a modern template engine for PHP" • Manual: https://twig.symfony.com • Script extension: .twig • Variables: {{   }} • Control statements: {%   %} • Comments: {#   #} Rob Allen ~ @akrabat

Action template {%   extends   '@layout/default.html.twig'   %} {%   block   title   %} Bitcoin converter {%   endblock   %} {%   block   content   %}      < h1

Bitcoin converter < / h1

     < p

One BTC: < / p

     {%   for   price   in   prices   %}          {{   price.symbol   }}          {{   price.rate_float | number_format ( 2 ,   '.' ,   ',' )   }} < br

     {%   endfor   %} {%   endblock   %} Rob Allen ~ @akrabat

Print variables {%   extends   '@layout/default.html.twig'   %} {%   block   title   %} Bitcoin converter {%   endblock   %} {%   block   content   %}      < h1

Bitcoin converter < / h1

     < p

One BTC: < / p

     {%   for   price   in   prices   %}          {{   price.symbol   }}          {{   price.rate_float | number_format ( 2 ,   '.' ,   ',' )   }} < br

     {%   endfor   %} {%   endblock   %} Rob Allen ~ @akrabat

Control statements {%   extends   '@layout/default.html.twig'   %} {%   block   title   %} Bitcoin converter {%   endblock   %} {%   block   content   %}      < h1

Bitcoin converter < / h1

     < p

One BTC: < / p

     {%   for   price   in   prices   %}          {{   price.symbol   }}          {{   price.rate_float | number_format ( 2 ,   '.' ,   ',' )   }} < br

     {%   endfor   %} {%   endblock   %} Rob Allen ~ @akrabat

Template inheritance {%   extends   '@layout/default.html.twig'   %} {%   block   title   %} Bitcoin converter {%   endblock   %} {%   block   content   %}      < h1

Bitcoin converter < / h1

     < p

One BTC: < / p

     {%   for   price   in   prices   %}          {{   price.symbol   }}          {{   price.rate_float | number_format ( 2 ,   '.' ,   ',' )   }} < br

     {%   endfor   %} {%   endblock   %} Rob Allen ~ @akrabat

Template inheritance • For cohesive look and feel • includes default CSS, JS & structural HTML • Build a base skeleton • Define blocks for children to override • Each child chooses which template to inherit from Rob Allen ~ @akrabat

Base skeleton src/App/templates/layout/default.html.twig: < !DOCTYPE html

< html

   < head

     {%   block   head   %}          < link   rel

"stylesheet"   href

"style.css"   /

         < title

{%   block   title   %}{%   endblock   %}  - Akrabat < / title

     {%   endblock   %}    < / head

   < body

     {%   block   content   %}{%   endblock   %}      < footer

{%   block   footer   %} & copy;  2017 {%   endblock   %} < / footer

   < / body

< / html

Rob Allen ~ @akrabat

Base skeleton src/App/templates/layout/default.html.twig: < !DOCTYPE html

< html

   < head

     {%   block   head   %}          < link   rel

"stylesheet"   href

"style.css"   /

         < title

{%   block   title   %}{%   endblock   %}  - Akrabat < / title

     {%   endblock   %}    < / head

   < body

     {%   block   content   %}{%   endblock   %}      < footer

{%   block   footer   %} & copy;  2017 {%   endblock   %} < / footer

   < / body

< / html

Rob Allen ~ @akrabat

Base skeleton src/App/templates/layout/default.html.twig: < !DOCTYPE html

< html

   < head

     {%   block   head   %}          < link   rel

"stylesheet"   href

"style.css"   /

         < title

{%   block   title   %}{%   endblock   %}  - Akrabat < / title

     {%   endblock   %}    < / head

   < body

     {%   block   content   %}{%   endblock   %}      < footer

{%   block   footer   %} & copy;  2017 {%   endblock   %} < / footer

   < / body

< / html

Rob Allen ~ @akrabat

Base skeleton src/App/templates/layout/default.html.twig: < !DOCTYPE html

< html

   < head

     {%   block   head   %}          < link   rel

"stylesheet"   href

"style.css"   /

         < title

{%   block   title   %}{%   endblock   %}  - Akrabat < / title

     {%   endblock   %}    < / head

   < body

     {%   block   content   %}{%   endblock   %}      < footer

{%   block   footer   %} & copy;  2017 {%   endblock   %} < / footer

   < / body

< / html

Rob Allen ~ @akrabat

. Rob Allen ~ @akrabat

Adding components Rob Allen ~ @akrabat

Add a form Rob Allen ~ @akrabat

Add a form: HTML < form   method

"GET"   action

"/bitcoin"

     < label

& pound; < / label

     < input   name

"amount"   value

" {{   amount   }} "

     < button

Convert < / button

< / form

< p

& pound; {{ amount }}  is  {{   bitcoins | number_format ( 6 )   }}  BTC Rob Allen ~ @akrabat

Add a form: HTML < form   method

"GET"   action

"/bitcoin"

     < label

& pound; < / label

     < input   name

"amount"   value

" {{   amount   }} "

     < button

Convert < / button

< / form

< p

& pound; {{ amount }}  is  {{   bitcoins | number_format ( 6 )   }}  BTC Rob Allen ~ @akrabat

Add a form: HTML < form   method

"GET"   action

"/bitcoin"

     < label

& pound; < / label

     < input   name

"amount"   value

" {{   amount   }} "

     < button

Convert < / button

< / form

< p

& pound; {{ amount }}  is  {{   bitcoins | number_format ( 6 )   }}  BTC Rob Allen ~ @akrabat

Add a form: HTML < form   method

"GET"   action

"/bitcoin"

     < label

& pound; < / label

     < input   name

"amount"   value

" {{   amount   }} "

     < button

Convert < / button

< / form

< p

& pound; {{ amount }}  is  {{   bitcoins | number_format ( 6 )   }}  BTC Rob Allen ~ @akrabat

Add a form: HTML < form   method

"GET"   action

"/bitcoin"

     < label

& pound; < / label

     < input   name

"amount"   value

" {{   amount   }} "

     < button

Convert < / button

< / form

< p

& pound; {{ amount }}  is  {{   bitcoins | number_format ( 6 )   }}  BTC < / p

Rob Allen ~ @akrabat

Zend-InputFilter   Rob Allen ~ @akrabat

Integration via ConfigProvider Rob Allen ~ @akrabat

Create an input filter $factory  

  new   Zend\InputFilter\Factory (); $inputFilter  

  $factory

createInputFilter ([      'amount'  

  [          'filters'  

  [              [ 'name'  

  'NumberParse' ],          ],          'validators'  

  [              [                  'name'  

  'GreaterThan' ,                  'options'  

  [ 'min'  

  0 ],              ],          ]      ], ]); Rob Allen ~ @akrabat

Create an input filter $factory  

  new   Zend\InputFilter\Factory (); $inputFilter  

  $factory

createInputFilter ([      'amount'  

  [          'filters'  

  [              [ 'name'  

  'ToInt' ],          ],          'validators'  

  [              [                  'name'  

  'GreaterThan' ,                  'options'  

  [ 'min'  

  0 ],              ],          ]      ], ]); Rob Allen ~ @akrabat

Create an input filter $factory  

  new   Zend\InputFilter\Factory (); $inputFilter  

  $factory

createInputFilter ([      'amount'  

  [          'filters'  

  [              [ 'name'  

  'ToInt' ],          ],          'validators'  

  [              [                  'name'  

  'GreaterThan' ,                  'options'  

  [ 'min'  

  0 ],              ],          ]      ], ]); Rob Allen ~ @akrabat

Create an input filter $factory  

  new   Zend\InputFilter\Factory (); $inputFilter  

  $factory

createInputFilter ([      'amount'  

  [          'filters'  

  [              [ 'name'  

  'ToInt' ],          ],          'validators'  

  [              [                  'name'  

  'GreaterThan' ,                  'options'  

  [ 'min'  

  0 ],              ],          ]      ], ]); Rob Allen ~ @akrabat

Create an input filter $factory  

  new   Zend\InputFilter\Factory (); $inputFilter  

  $factory

createInputFilter ([      'amount'  

  [          'filters'  

  [              [ 'name'  

  'ToInt' ],          ],          'validators'  

  [              [                  'name'  

  'GreaterThan' ,                  'options'  

  [ 'min'  

  0 ],              ],          ]      ], ]); Rob Allen ~ @akrabat

Validating request data 1. Retrieve data from Request object 2. Pass to InputFilter 3. Call isValid() 4. Retrieve sanitized, valid data using getValues() 5. Use getMessages() to find out what failed Rob Allen ~ @akrabat

Validating request data public   function   handle ( ServerRequestInterface   $request ) {      $requestData  

  $request

getQueryParams ();      $this

inputFilter

setData ( $requestData );      if   ( $this

inputFilter

isValid ())   {          /* request data is valid */          $values  

  $this

inputFilter

getValues ();          $btc  

  $this

btcService

convert ( $values [ 'amount' ]);      }   else   {          /* request data is invalid */          $errors  

  $this

inputFilter

getMessages ();      } Rob Allen ~ @akrabat

Validating request data public   function   handle ( ServerRequestInterface   $request ) {      $requestData  

  $request

getQueryParams ();      $this

inputFilter

setData ( $requestData );      if   ( $this

inputFilter

isValid ())   {          /* request data is valid */          $values  

  $this

inputFilter

getValues ();          $btc  

  $this

btcService

convert ( $values [ 'amount' ]);      }   else   {          /* request data is invalid */          $errors  

  $this

inputFilter

getMessages ();      } Rob Allen ~ @akrabat

Validating request data public   function   handle ( ServerRequestInterface   $request ) {      $requestData  

  $request

getQueryParams ();      $this

inputFilter

setData ( $requestData );      if   ( $this

inputFilter

isValid ())   {          /* request data is valid */          $values  

  $this

inputFilter

getValues ();          $btc  

  $this

btcService

convert ( $values [ 'amount' ]);      }   else   {          /* request data is invalid */          $errors  

  $this

inputFilter

getMessages ();      } Rob Allen ~ @akrabat

Validating request data public   function   handle ( ServerRequestInterface   $request ) {      $requestData  

  $request

getQueryParams ();      $this

inputFilter

setData ( $requestData );      if   ( $this

inputFilter

isValid ())   {          /* request data is valid */          $values  

  $this

inputFilter

getValues ();          $btc  

  $this

btcService

convert ( $values [ 'amount' ]);      }   else   {          /* request data is invalid */          $errors  

  $this

inputFilter

getMessages ();      } Rob Allen ~ @akrabat

Validating request data public   function   handle ( ServerRequestInterface   $request ) {      $requestData  

  $request

getQueryParams ();      $this

inputFilter

setData ( $requestData );      if   ( $this

inputFilter

isValid ())   {          /* request data is valid */          $values  

  $this

inputFilter

getValues ();          $btc  

  $this

btcService

convert ( $values [ 'amount' ]);      }   else   {          /* request data is invalid */          $errors  

  $this

inputFilter

getMessages ();      } Rob Allen ~ @akrabat

Summary Rob Allen ~ @akrabat

Resources • https://docs.zendframework.com/zend-expressive/ • https://github.com/zendframework/ • https://akrabat.com/category/zend-expressive/ • https://framework.zend.com/blog • Zend Expressive Essentials by Matt Setter Rob Allen ~ @akrabat

Thank you! https://joind.in/talk/52ee1 Rob Allen ~ @akrabat