1
\$\begingroup\$

Have I followed best practices for PHP development, or can this class be improved?

GitHub

<?php namespace azi;
/**
 * Class Validator
 *
 * @package azi
 * @author  Azi Baloch <http://www.azibaloch.com>
 * @version 1.0
 * @license The MIT License (MIT)
 *
 */
class Validator {
    /**
     * RegExp patterns
     *
     * @var array
     */
    private $expressions = [ ];
    /**
     * Custom RegExp error messages
     *
     * @var array
     */
    private $error_messages = [ ];
    /**
     * holds validation errors
     *
     * @var array
     */
    private $validation_errors = [ ];
    /**
     * @var array
     */
    private static $errors = [ ];
    /**
     * @var null
     */
    private static $instance = null;
    /**
     * @var string
     */
    private static $session_data_key = "form_validation_errors";
    /**
     * @var array
     */
    private $builtin_rules = [];
    /**
     *  Class Constructor
     */
    public function __construct() {
        // load built-in expressions
        $this->expressions = array(
            'alpha'     => '#^([a-zA-Z\s])+$#',
            'num'       => '#^([0-9])+$#',
            'alpha-num' => '#^([a-zA-Z0-9\s])$#',
        );
        $this->builtin_rules = array(
            array(
                'id' => 'email',
                'exp' => '#^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$#',
                'message' => 'Please enter a valid email address'
            )
        );
        foreach($this->builtin_rules as $rule) {
            $rule = (object) $rule;
            $this->registerExpression($rule->id, $rule->exp, $rule->message);
        }
        static::$instance = $this;
    }
    /**
     * @param $key
     * @param $expression
     * @param null $message
     *
     * @return bool
     * @throws \Exception
     */
    public function registerExpression( $key, $expression, $message = null ) {
        if ( ! isset( $this->expressions[ $key ] ) ) {
            $this->expressions[ $key ] = $expression;
            if ( $message ) {
                $this->error_messages[ $key ] = $message;
            }
            return true;
        }
        throw new \Exception( "Expression key already exists" );
    }
    /**
     * @param $key
     * @param $newExpression
     * @param null $message
     *
     * @return bool
     * @throws \Exception
     */
    public function updateExpression( $key, $newExpression, $message = null ) {
        if ( $this->expressions[ $key ] ) {
            $this->expressions[ $key ] = $newExpression;
            if ( $message ) {
                $this->error_messages[ $key ] = $message;
            }
            return true;
        }
        throw new \Exception( "Expression dose not exists" );
    }
    /**
     * @return bool
     */
    public function passed() {
        if ( count( $this->validation_errors ) < 1 ) {
            return true;
        }
        return false;
    }
    /**
     * retrieve error messages after validation
     *
     * @return array
     */
    public function getErrors() {
        return $this->validation_errors;
    }
    /**
     * Get error message of a field
     *
     * @param $fieldKey
     *
     * @return mixed
     */
    public static function error( $fieldKey ) {
        if(!session_id()) {
            session_start();
        }
        if(isset($_SESSION[static::$session_data_key])) {
            if(count($_SESSION[static::$session_data_key]) > 0) {
                self::$errors = $_SESSION[ static::$session_data_key ];
                unset($_SESSION[static::$session_data_key]);
            }
        } else {
            if(!is_null(static::$instance)) {
                self::$errors = static::$instance->validation_errors;
            }
        }
        if ( isset( self::$errors[ $fieldKey ][ 'message' ] ) ) {
            return self::$errors[ $fieldKey ][ 'message' ];
        }
        return false;
    }
    /**
     * @param array $fields the array of form fields - ( $_POST , $_GET, $_REQUEST )
     * @param $rules
     *
     * @return Validator $this
     */
    public function validate( $fields, $rules ) {
        $return = [ ];
        foreach ( $fields as $key => $field ) {
            if ( ! array_key_exists( $key, $rules ) ) {
                continue;
            }
            $r       = $rules[ $key ];
            $matches = [ ];
            $not     = false;
            if ( preg_match( '#if:#', $r ) ) {
                preg_match( "#if:(.*)\\[\\!(.*)\\]:(.*)\\[!(.*)\\]\\((.*)\\)#", $r, $matches );
                if ( count( $matches ) < 1 ) {
                    preg_match( "#if:(.*)\\[(.*)\\]\\((.*)\\)#", $r, $matches );
                } else {
                    $not = true;
                }
                $r = end( $matches );
            }
            if ( ! strpos( $r, "|" ) ) {
                $r .= "|IGNORE_ME5";
            }
            $theRules = explode( "|", $r );
            foreach ( $theRules as $theRule ) {
                if ( $theRule == "IGNORE_ME5" ) {
                    continue;
                }
                $customMessage = [ ];
                if ( strpos( $theRule, "--" ) ) {
                    $rcm     = explode( "--", $theRule ); // custom message for current rule
                    $theRule = $rcm[ 0 ];
                    if ( strpos( $rcm[ 0 ], ":" ) ) {
                        $rcm[ 0 ] = explode( ":", $rcm[ 0 ] )[ 0 ];
                    }
                    $customMessage[ $rcm[ 0 ] ] = $rcm[ 1 ];
                }
                if ( count( $matches ) > 0 ) {
                    if ( $not ) {
                        if ( $fields[ $matches[ 1 ] ] == $matches[ 2 ] && $fields[ $matches[ 3 ] ] == $matches[ 4 ] ) {
                            continue;
                        }
                    } else {
                        if ( $fields[ $matches[ 1 ] ] != $matches[ 2 ] ) {
                            continue;
                        }
                    }
                }
                if ( strtolower( $theRule ) == "required" ) {
                    if ( ! empty( $customMessage[ 'required' ] ) ) {
                        $theMessage = $customMessage[ 'required' ];
                    } else {
                        $theMessage = $this->keyToLabel( $key ) . ' is required';
                    }
                    if ( $field == "" ) {
                        $return[ $key ] = [
                            'error'   => 'required',
                            'message' => $theMessage
                        ];
                        continue;
                    }
                }
                if ( strtolower( $theRule ) == "alpha" ) {
                    if ( ! preg_match( $this->expressions[ 'alpha' ], $field ) ) {
                        if ( ! empty( $customMessage[ 'alpha' ] ) ) {
                            $theMessage = $customMessage[ 'alpha' ];
                        } else {
                            $theMessage = $this->keyToLabel( $key ) . ' must not contain numbers and special characters';
                        }
                        $return[ $key ] = [
                            'error'   => 'alpha',
                            'message' => $theMessage
                        ];
                        continue;
                    }
                }
                if ( strtolower( $theRule ) == "num" ) {
                    if ( ! empty( $customMessage[ 'num' ] ) ) {
                        $theMessage = $customMessage[ 'num' ];
                    } else {
                        $theMessage = $this->keyToLabel( $key ) . ' may only contain numbers';
                    }
                    if ( ! preg_match( $this->expressions[ 'num' ], $field ) ) {
                        $return[ $key ] = [
                            'error'   => 'num',
                            'message' => $theMessage
                        ];
                        continue;
                    }
                }
                if ( strtolower( $theRule ) == "alpha-num" ) {
                    if ( ! empty( $customMessage[ 'alpha-num' ] ) ) {
                        $theMessage = $customMessage[ 'alpha-num' ];
                    } else {
                        $theMessage = $this->keyToLabel( $key ) . ' may only contain alpha numeric characters';
                    }
                    if ( ! preg_match( $this->expressions[ 'alpha-num' ], $field ) ) {
                        $return[ $key ] = [
                            'error'   => 'alpha-num',
                            'message' => $theMessage
                        ];
                        continue;
                    }
                }
                if ( strpos( $theRule, ':' ) ) {
                    $theRule = explode( ":", $theRule );
                    $length = $theRule[1];
                    $theRule = $theRule[0];
                    if ( strtolower( $theRule ) == "min" ) {
                        if ( ! empty( $customMessage[ 'min' ] ) ) {
                            $theMessage = $customMessage[ 'min' ];
                        } else {
                            $theMessage = $this->keyToLabel( $key ) . ' must be at least ' . $length . " characters long";
                        }
                        if ( strlen( $field ) < $length ) {
                            $return[ $key ] = [
                                'error'   => 'min',
                                'message' => $theMessage
                            ];
                            continue;
                        }
                    }
                    if ( strtolower( $theRule ) == "max" ) {
                        if ( ! empty( $customMessage[ 'max' ] ) ) {
                            $theMessage = $customMessage[ 'max' ];
                        } else {
                            $theMessage = $this->keyToLabel( $key ) . ' must be less than ' . $length . " characters";
                        }
                        if ( strlen( $field ) > $length ) {
                            $return[ $key ] = [
                                'error'   => 'max',
                                'message' => $theMessage
                            ];
                            continue;
                        }
                    }
                }
                /* Custom Expressions */
                if ( array_key_exists( $theRule, $this->expressions ) ) {
                    if ( ! preg_match( $this->expressions[ $theRule ], $field ) ) {
                        if(isset($customMessage[$theRule])) {
                            $error_message = $customMessage[$theRule];
                        } else if ( array_key_exists( $theRule, $this->error_messages ) ) {
                            $error_message = $this->error_messages[ $theRule ];
                        } else {
                            $error_message = $this->keyToLabel( $key ) . ' dose\'t match the required pattern';
                        }
                        $return[ $key ] = [
                            'error'   => $theRule,
                            'message' => $error_message
                        ];
                        continue;
                    }
                }
            }
        }
        $this->validation_errors = $return;
        return $this;
    }
    /**
     * Convert an array key to Label eg. full_name to Full Name
     *
     * @param $key
     *
     * @return string
     */
    private function keyToLabel( $key ) {
        return ucwords( str_replace( [ '-', '_', '+' ], " ", $key ) );
    }
    public function goBackWithErrors(){
        if(!session_id()) {
            session_start();
        }
        $_SESSION[static::$session_data_key] = $this->validation_errors;
        header('Location: ' . $_SERVER['HTTP_REFERER']);
        exit;
    }
}
\$\endgroup\$
1
  • 2
    \$\begingroup\$ The validate functions is WAY too hard to read. I would create more methods, with that do just one thing "Single Responsibility Principle". Work in making your code more readable. \$\endgroup\$ Commented Mar 27, 2015 at 18:35

2 Answers 2

3
\$\begingroup\$

Look at Symfony 2 Validation and you will find many useful things.

About the code split validate method to small prices: it should not take more than one screen.

Use protected instead of private if you are not 100% sure it must be private.

In your case, $session_data_key must be constant.

If you are providing a library, it must be unit tested and when you will start do it - you will find many other architectural problems.

\$\endgroup\$
0
1
\$\begingroup\$

I'd like to point a few other ground-level improvements:

  • Write class and method opening curly brace on the next line.

  • Regarding regular expressions:

    • alpha is more accurately labeled as alpha-white (letters and any whitespace characters -- including newlines and tabs) and can be simply expressed as:

      #^[a-z\s]+$#i
      
    • num can be simply expressed as:

      #^\d+$#
      
    • alpha-num is more accurately labelled as alpha-num-white and can be simply expressed as:

      #^[a-z\d\s]$#i
      
    • the email expression is a little too primitive and is going to give false negative for a lot of valid email address. This topic too much to discuss here, so I recommend reading up on what makes an email address valid. Your posted pattern could be tightened to:

      #^[\w.&+-]+@[a-z\d.-]+\.[a-z]{2,4}$#i
      

    Overall, none of the patterns need capture grouping (parenthetical grouping)

  • Your class properties have declared data types, but your methods do not -- I recommend data typing / hinting everywhere possible to help keep your application bug free and fit.

  • $rule = (object) $rule; seems like a wasted line of code. If you want to deal with these data points as objects, then declare them as objects from the beginning. I don't see any benefit in converting the subarrays as objects and then only distributing the 3 properties to registerExpression(). Instead, you could just write:

    $this->registerExpression($rule['id'], $rule['exp'], $rule['message']);
    

    Alternatively, you could shorten the syntax and allow message to not be declared by using the spread operator:

    $this->registerExpression(...array_values($rule));
    
  • I don't know the context in which updateExpression() will be called, but it doesn't check whether the $key actually exists in $this->expressions. I am expecting to see isset( $this->expressions[ $key ] ). This modification makes the method nearly identical to registerExpression() which might beg the question can the two methods be sensibly merge? Do you actually need these leading conditions at all? Can you not just force the addition of the passed-in rule into the $this->expressions array? IOW, if the rule doesn't exist, then it will; if it does exist, then it will be overwritten.

  • I find passed() a little to verbose for the simple thing that it does. More simply use:

    return !count( $this->validation_errors );
    
  • Regarding error():

    • To maintain session data between page loads, session_start() needs to be unconditionally declared -- ideally just once at the start of the earliest loaded file.

    • You can afford to use better condition structures:

      if ( !empty( $_SESSION[static::$session_data_key] ) ) {
          self::$errors = $_SESSION[ static::$session_data_key ];
          unset($_SESSION[static::$session_data_key]);
      } elseif ( static::$instance !== null ) {
          self::$errors = static::$instance->validation_errors;
      }
      
    • I personally to not use boolean fallback return values when a method primarily returns a string. In these cases, I use null so that the return data type can be nullable ?string. This doesn't impede your ability to make a falsey check where the method is being called.

  • I don't have any unit tests to help me to understand the intended functionality of validate() but...

    • does $rules need to be passed in or can you just reference $this->expressions?

    • regardless of the answer to the following question, you can pre-filter $fields by key comparisions against $rules using:

      foreach ( array_intersect_key( $fields, $rules ) as $key => $field ) {
      
    • you don't need to call count( $matches ) instead, just declare the return value from preg_match() as the count -- so effective you can use the same style condition as the earlier line.

      if ( preg_match( '#if:#', $r ) ) {
          if ( ! preg_match( "#if:(.*)\\[\\!(.*)\\]:(.*)\\[!(?:.*)\\]\\((.*)\\)#", $r, $matches ) {
              preg_match( "#if:(.*)\\[(.*)\\]\\((.*)\\)#", $r, $matches );
          } else {
              $not = true;
          }
          $r = end($matches);
      }
      
  • I generally don't like the look of ! strpos(...) in anyone's code. Even if the sought substring is not possibly at the first offset, it just makes the code look more deliberate and safe if !== false is explicitly written. This coding concern is in multiple places in your script.

  • Why in the world is IGNORE_ME5 conditionally added as a consideration and then unconditionally ignored in the next step? I don't understand.

  • Why isn't $r declared as an array to begin with and new rules pushed into the array? It is unnecessarily complex to forge a pipe-separated string only to instantly explode it on pipes. Again, I don't get it.

  • I find all of the explosions on | and -- and :.

  • Why do you bother declaring $theRule = $rcm[ 0 ];, then continue to access $rcm[ 0 ] when $theRule is cleaner to read?

  • When you want to isolate the substring before the first occurrence of a substring, then use strstr(haystack, needle, true). When you know you only intend to cut a string into to parts, then declare the third argument of explode() as 2. This give absolute clarity to the developer. It is often beneficial to the developer to use list() or array destructuring to make the code more declarative.

    [$theRule, $theOtherPart] = explode('--', $theRule, 2);
    
  • I don't see $not being vital.

    switch ( count ( $matches ) ) {
        case 5:
            if ( $fields[ $matches[ 1 ] ] == $matches[ 2 ] && $fields[ $matches[ 3 ] ] == $matches[ 4 ] ) {
                continue 2;
            }
        case 3: 
            if ( $fields[ $matches[ 1 ] ] != $matches[ 2 ] ) {
                continue 2;
            }
    }
    
  • The null coalescing operator seems appropriate (in multiple places) when defining $theMessage:

    $theMessage = $customMessage[ 'required' ] ?? $this->keyToLabel( $key ) . ' is required';
    
  • must not contain numbers and special characters is not a very accurate way of expressing the rule. It would be better to explain that "only letters and whitespace characters are accepted". If you need to respect multibyte characters, you will need to bake that into your pattern (u pattern modifier) and messaging.

  • I find a lot of duplicated code relating to required, alpha, num, and alpha-num. Try to make your script more scalable by crafting a protected method which will receive dynamic data and return what you require in $return.

  • Rather than writing nested if structures, combine the logic using &&.

    if ( array_key_exists( $theRule, $this->expressions )
        &&  ! preg_match( $this->expressions[ $theRule ], $field ) ) {
    
  • else if should be one word in php elseif. dose should be spelled does in two places.

  • After spending a fair amount of time reviewing your code, I don't enjoy the excessive spacing. There is emerging support for this style of coding, mostly with the stance that it improves readability for vision impaired developers, but I find the horizontal code bloat to be too annoying -- it unnecessarily forces lines of code beyond the recommended limit and makes me horizontally scroll more than I like to.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.