Categories
MVC PHP

Filtering Access to Controller Classes and Methods (before PHP 8)

A feature of good routing is the possibility to enforce policies that get specified for Controller classes and methods. For this purpose, frameworks such as ASP.NET use annotations on classes and methods. We wish to replicate this in PHP, but PHP does not support annotations.

phpDoc Comments

phpDoc comments are the official means to document class and function API in PHP. These comments are delimited by /** and */ character sequences (note the opening tag has 2* characters. The usual contents should include method parameters, return values, summaries, etc. We aim to add our own annotation to this phpDoc which we will in turn use to filter user access.

Consider a simple controller class with phpDoc comments as below:

  1. <?php
  2. /**
  3. HomeController will handle operations related to Item entities.
  4. @accessFilter{LoginFilter}
  5. */
  6. class HomeController extends Controller{
  7. /**
  8. Provide a listing of the Item entities
  9. */
  10.   public function index(){
  11.     //...
  12.   }
  13.  
  14.   public function create(){
  15.     //...
  16.   }
  17. /** 
  18. @accessFilter{itemOwner}
  19. */
  20.   public function detail($item_id){
  21.     //...
  22.   }
  23. /**
  24. @accessFilter{itemOwner}
  25. */
  26.   public function edit($item_id){
  27.     //...
  28.   }
  29. /**
  30. @accessFilter{itemOwner}
  31. */
  32.   public function delete($item_id){
  33.     //...
  34.   }
  35. }
  36. ?>

In the above, we wish to apply the LoginFilter access policy to the entire class and the itemOwner policy only to Detail, Edit, and Delete methods.

ReflectionClass

For our purposes, phpDoc comments would serve no purpose without the means to efficiently extract them programatically. For this purpose, we use the ReflectionClass class, which provides an interface to all information about a selected class and its methods. Below, we extract the phpDoc comments of the class and each one of the methods:

  1. <?php
  2. $reflection = new ReflectionClass('HomeController');
  3. $classDoc = $reflection->getDocComment();
  4. $indexComment = $reflection->getMethod('index')->getDocComment();
  5. $createComment = $reflection->getMethod('create')->getDocComment();
  6. $detailComment = $reflection->getMethod('detail')->getDocComment();
  7. $editComment = $reflection->getMethod('edit')->getDocComment();
  8. $deleteComment = $reflection->getMethod('delete')->getDocComment();
  9. ?>

More specifically for our purposes, we need to extract the phpDoc comments for the specific controller and method resolved in the routing algorithm. (If you have been reading this site, I place this functionality in the App class constructor.)

Below is an example of how, given a class, method, and parameters, we should extract, parse, and resolve the filters.

  1. private static function redirectFilters($class,$method, $params){
  2.   $reflection = new ReflectionClass($class);
  3.  
  4.   $classDocComment = $reflection-&gt;getDocComment();
  5.   $methodDocComment = $reflection-&gt;getMethod($method)-&gt;getDocComment();
  6.  
  7.   //parse and extract the filters
  8.   $classFilters = self::getFiltersFromAnnotations($classDocComment);
  9.   $methodFilters = self::getFiltersFromAnnotations($methodDocComment);
  10.  
  11.   $filters = array_values(array_filter(array_merge($classFilters,$methodFilters)));
  12.  
  13.   $redirect = self::runFilters($filters, $params);
  14.   return $redirect;
  15. }

First, we initialize the ReflectionClass instance for this controller class and then extract the phpDoc comments for the class and method at hand. Then, we parse the phpDoc comment to retrieve any filter annotations and assemble these from the class and method in a single array. Then we run the filters and return the first redirection URL obtained, false otherwise. So the right time to call this method is right before calling the method on the controller class, as follows:

  1. if($redirectUrl = self::redirectFilters($this-&gt;controller, $this-&gt;method, $this-&gt;params)){
  2.   header("location:$redirectUrl");
  3.   return;
  4. }
  5.  
  6. call_user_func_array([$this-&gt;controller, $this-&gt;method], $this-&gt;params);

For any of this to work, of course, we must actually parse the phpDoc comments to extract the filter information, run the filters, and especially have filters to run in our code.

Parsing the phpDoc Comment

We must extract information from the phpDoc comments which resemble the following:

/**
Summary.
@accessFilter{filter1,filter2, filter3}
*/

For this purpose, we propose a method as follows:

  1. private static function getFiltersFromAnnotations($docComment){
  2.   preg_match('/@accessFilter:{(?<content>.+)}/i', $docComment, $content);
  3.   $content = (isset($content['content'])?$content['content']:'');
  4.   $content = explode(',',str_replace(' ', '', $content));
  5.   return $content;//this is an array
  6. }

On line 2, we extract all the accessFilter contents as elements in the $content array, with the key ‘content‘.

On line 3, we set $content to the value associated to the ‘content‘ key in $content, if any, or an empty string otherwise.

On line 4, we convert the string to an array of strings, one element per filter, without space characters.

Running the Filters

First and foremost, we must have filters to run. It is proposed to group these either in your routing class or a separate Filter class, as follows:

  1. <?php
  2. <?php
  3. class Filter extends Controller{
  4.   public static function itemOwner($params){
  5.     $theItem = self::model('Item')->find($params[0]);
  6.     if($theItem->user_id != $_SESSION['user_id']){
  7.       return '/home/index';
  8.     }else{
  9.       return false;
  10.     }
  11.   }
  12.   public static function LoginFilter($params){
  13.     if($_SESSION['user_id'] == null){
  14.       return '/login/index';
  15.     }else{
  16.       return false;
  17.     }		
  18.   }
  19. }
  20. ?>

Each of the above methods will check conditions based on the session variables, parameters, or core functionality. For example, LoginFilter verifies if a user is logged in and returns a redirection URL otherwise, or false if the user need not be redirected. The function itemOwner compares the user id of the user logged in with the user id in the record to verify if the current user is not attempting to modify someone else’s data.

The strategy to run the filters is to run them one by one until a redirection is determined or all have been run. We then return the redirection to the calling method which will send it to the routing algorithm.

  1. private static function runfilters($filters,$params){
  2.   $redirect = false;
  3.   $max = count($filters);
  4.   $i = 0;
  5.   while(!$redirect && $i < $max){
  6.     if(method_exists('Filter', $filters[$i])){
  7.       $redirect = Filter::{$filters[$i]}($params);
  8.     }else{
  9.       throw new Exception("No policy named $filters[$i]");
  10.     }
  11.     $i++;
  12.   }
  13.   return $redirect;
  14. }

Conclusion

All the pieces are in place. We can now implement redirection policies and apply them to classes and methods with a simple phpDoc comment. Note that there should only be ONE phpDoc comment per class and per method, which integrates all relevant annotations, including ours: @accessFilter. Otherwise, the ReflectionClass method getDocComment will only be able to return the one before and closest to the class or method declaration.

By Michel

My name is Michel Paquette. I currently teach my students how to create data-driven Web applications at Vanier College, in Montreal.

My GitHub page contains a few examples of Web applications. Also consult my YouTube channels: @CSTutoringDotCa and @MichelPaquette.

2 replies on “Filtering Access to Controller Classes and Methods (before PHP 8)”

Hi,

Is it possible to receive the complete working source code for the PHP MVC application as explained on Youtube.

regards,
Danny

Leave a Reply

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.