The following code is a secure implementation of PBKDF2 hashing in PHP.

<?php

class InvalidHashException extends Exception
{
}

class CannotPerformOperationException extends Exception
{
}

class PasswordStorage
{
    //
    // These constants may be changed without breaking existing hashes.
    const PBKDF2_HASH_ALGORITHM = 'sha1';
    const PBKDF2_ITERATIONS = 64000;
    const PBKDF2_SALT_BYTES = 24;
    const PBKDF2_OUTPUT_BYTES = 18;

    //
    // These constants define the encoding and may not be changed.
    const HASH_SECTIONS = 5;
    const HASH_ALGORITHM_INDEX = 0;
    const HASH_ITERATION_INDEX = 1;
    const HASH_SIZE_INDEX = 2;
    const HASH_SALT_INDEX = 3;
    const HASH_PBKDF2_INDEX = 4;

    /**
     * Hash a password with PBKDF2.
     *
     * @param string $password
     * @throws InvalidArgumentException        Thrown if $password is not a
     *                                         string.
     *
     * @throws CannotPerformOperationException Throw if random number generator
     *                                         failed. Not safe to proceed.
     *
     * @return string The hashed password.
     */
    public static function createHash($password)
    {
        // format: algorithm:iterations:outputSize:salt:pbkdf2output
        if (!\is_string($password)) {
            throw new InvalidArgumentException(
                'createHash(): Expected a string'
            );
        }

        if (\function_exists('random_bytes')) {
            try {
                $salt_raw = \random_bytes(self::PBKDF2_SALT_BYTES);
            } catch (Error $e) {
                $salt_raw = false;
            } catch (Exception $e) {
                $salt_raw = false;
            } catch (TypeError $e) {
                $salt_raw = false;
            }
        } else {
            $salt_raw = \mcrypt_create_iv(self::PBKDF2_SALT_BYTES, MCRYPT_DEV_URANDOM);
        }

        if ($salt_raw === false) {
            throw new CannotPerformOperationException(
                'Random number generator failed. Not safe to proceed.'
            );
        }

        $PBKDF2_Output = self::pbkdf2(
            self::PBKDF2_HASH_ALGORITHM,
            $password,
            $salt_raw,
            self::PBKDF2_ITERATIONS,
            self::PBKDF2_OUTPUT_BYTES,
            true
        );

        return self::PBKDF2_HASH_ALGORITHM.
            ':'.
            self::PBKDF2_ITERATIONS.
            ':'.
            self::PBKDF2_OUTPUT_BYTES.
            ':'.
            \base64_encode($salt_raw).
            ':'.
            \base64_encode($PBKDF2_Output);
    }

    /**
     * Verify that a password matches the stored hash.
     *
     * @param string $password
     * @param string $hash
     *
     * @throws InvalidArgumentException
     * @throws InvalidHashException
     *
     * @return bool
     */
    public static function verifyPassword($password, $hash)
    {
        if (!\is_string($password) || !\is_string($hash)) {
            throw new InvalidArgumentException(
                'verifyPassword(): Expected two strings'
            );
        }

        $params = \explode(':', $hash);
        if (\count($params) !== self::HASH_SECTIONS) {
            throw new InvalidHashException(
                'Fields are missing from the password hash.'
            );
        }

        $pbkdf2 = \base64_decode($params[self::HASH_PBKDF2_INDEX], true);
        if ($pbkdf2 === false) {
            throw new InvalidHashException(
                'Base64 decoding of pbkdf2 output failed.'
            );
        }

        $salt_raw = \base64_decode($params[self::HASH_SALT_INDEX], true);
        if ($salt_raw === false) {
            throw new InvalidHashException(
                'Base64 decoding of salt failed.'
            );
        }

        $storedOutputSize = (int) $params[self::HASH_SIZE_INDEX];
        if (self::ourStrlen($pbkdf2) !== $storedOutputSize) {
            throw new InvalidHashException(
                "PBKDF2 output length doesn't match stored output length."
            );
        }

        $iterations = (int) $params[self::HASH_ITERATION_INDEX];
        if ($iterations < 1) {
            throw new InvalidHashException(
                'Invalid number of iterations. Must be >= 1.'
            );
        }

        return self::slowEquals(
            $pbkdf2,
            self::pbkdf2(
                $params[self::HASH_ALGORITHM_INDEX],
                $password,
                $salt_raw,
                $iterations,
                self::ourStrlen($pbkdf2),
                true
            )
        );
    }

    /**
     * Compares two strings in length-constant time.
     *
     * @param string $firstStr
     * @param string $secondStr
     * @throws InvalidArgumentException Thrown if arguments are not strings.
     * @return bool Returns true if strings have the same length, otherwise
     * false.
     */
    private static function slowEquals($firstStr, $secondStr)
    {
        if (!\is_string($firstStr) || !\is_string($secondStr)) {
            throw new InvalidArgumentException(
                'slowEquals(): expected two strings'
            );
        }

        if (\function_exists('hash_equals')) {
            return \hash_equals($firstStr, $secondStr);
        }

        // PHP < 5.6 polyfill:
        $diff = self::ourStrlen($firstStr) ^ self::ourStrlen($secondStr);
        for ($i = 0; $i < self::ourStrlen($firstStr) &&
            $i < self::ourStrlen($secondStr); ++$i) {
            $diff |= \ord($firstStr[$i]) ^ \ord($secondStr[$i]);
        }

        return $diff === 0;
    }

    /**
     * PBKDF2 key derivation function as defined by RSA's PKCS #5:
     * https://www.ietf.org/rfc/rfc2898.txt.
     *
     * Test vectors can be found here: https://www.ietf.org/rfc/rfc6070.txt
     *
     * This implementation of PBKDF2 was originally created by https://defuse.ca
     * With improvements by http://www.variations-of-shadow.com
     *
     * @param string $algorithm  The hash algorithm to use. Recommended: SHA256.
     * @param string $password   The password.
     * @param string $salt       A salt that is unique to the password.
     * @param int    $count      Iteration count. Higher is better, but slower.
     *                           Recommended: At least 1000.
     *
     * @param int    $key_length The length of the derived key in bytes.
     * @param bool   $raw_output If true, the key is returned in raw binary
     *                           format. Hex encoded otherwise.
     *
     * @throws InvalidArgumentException         Thrown when the $algorithm or
     *                                          $salt arguments are not a
     *                                          string.
     *
     * @throws CannotPerformOperationException  Thrown when the $algorithm
     *                                          argument is invalid, or
     *                                          unsupported hash algorithm.
     *
     * @return string A $key_length-byte key derived from the password and salt.
     */
    public static function pbkdf2($algorithm, $password, $salt, $count, $key_length, $raw_output = false)
    {
        // Type checks:
        if (!\is_string($algorithm)) {
            throw new InvalidArgumentException(
                'pbkdf2(): algorithm must be a string'
            );
        }

        if (!\is_string($password)) {
            throw new InvalidArgumentException(
                'pbkdf2(): password must be a string'
            );
        }

        if (!\is_string($salt)) {
            throw new InvalidArgumentException(
                'pbkdf2(): salt must be a string'
            );
        }

        // Coerce strings to integers with no information loss or overflow
        $count += 0;
        $key_length += 0;

        $algorithm = \strtolower($algorithm);
        if (!\in_array($algorithm, \hash_algos(), true)) {
            throw new CannotPerformOperationException(
                'Invalid or unsupported hash algorithm.'
            );
        }

        // Whitelist, or we could end up with people using CRC32.
        $ok_algorithms = array(
            'sha1', 'sha224', 'sha256', 'sha384', 'sha512',
            'ripemd160', 'ripemd256', 'ripemd320', 'whirlpool',
        );
        if (!\in_array($algorithm, $ok_algorithms, true)) {
            throw new CannotPerformOperationException(
                'Algorithm is not a secure cryptographic hash function.'
            );
        }

        if ($count <= 0 || $key_length <= 0) {
            throw new CannotPerformOperationException(
                'Invalid PBKDF2 parameters.'
            );
        }

        if (\function_exists('hash_pbkdf2')) {
            // The output length is in NIBBLES (4-bits) if $raw_output is false!
            if (!$raw_output) {
                $key_length = $key_length * 2;
            }

            // Generate a PBKDF2 key derivation of a supplied password:
            return \hash_pbkdf2($algorithm, $password, $salt, $count, $key_length, $raw_output);
        }

        $hash_length = self::ourStrlen(\hash($algorithm, '', true));
        $block_count = \ceil($key_length / $hash_length);

        $output = '';
        for ($i = 1; $i <= $block_count; ++$i) {
            // $i encoded as 4 bytes, big endian.
            $last = $salt.\pack('N', $i);

            // first iteration
            $last = $xorsum = \hash_hmac($algorithm, $last, $password, true);

            // perform the other $count - 1 iterations
            for ($j = 1; $j < $count; ++$j) {
                $xorsum ^= ($last = \hash_hmac($algorithm, $last, $password, true));
            }

            $output .= $xorsum;
        }

        if ($raw_output) {
            return self::ourSubstr($output, 0, $key_length);
        } else {
            return \bin2hex(self::ourSubstr($output, 0, $key_length));
        }
    }

    /*
     * We need these strlen() and substr() functions because when
     * 'mbstring.func_overload' is set in php.ini, the standard strlen() and
     * substr() are replaced by mb_strlen() and mb_substr().
     */

    /**
     * Calculate the length of a string.
     *
     * @param string $str
     * @throws InvalidArgumentException
     * @throws CannotPerformOperationException
     * @return int The length of the string.
     */
    private static function ourStrlen($str)
    {
        static $exists = null;
        if ($exists === null) {
            $exists = \function_exists('mb_strlen');
        }

        if (!\is_string($str)) {
            throw new InvalidArgumentException(
                'ourStrlen() expects a string'
            );
        }

        if ($exists) {
            $length = \mb_strlen($str, '8bit');
            if ($length === false) {
                throw new CannotPerformOperationException();
            }

            return $length;
        } else {
            return \strlen($str);
        }
    }

    /**
     * Substring.
     *
     * @param string $str
     * @param int    $start
     * @param int    $length
     * @throws InvalidArgumentException
     * @return string
     */
    private static function ourSubstr($str, $start, $length = null)
    {
        static $exists = null;
        if ($exists === null) {
            $exists = \function_exists('mb_substr');
        }
        // Type validation:
        if (!\is_string($str)) {
            throw new InvalidArgumentException(
                'ourSubstr() expects a string'
            );
        }

        if ($exists) {
            // mb_substr($str, 0, NULL, '8bit') returns an empty string on PHP
            // 5.3, so we have to find the length ourselves.
            if (!isset($length)) {
                if ($start >= 0) {
                    $length = self::ourStrlen($str) - $start;
                } else {
                    $length = -$start;
                }
            }

            return \mb_substr($str, $start, $length, '8bit');
        }

        // Unlike mb_substr(), substr() doesn't accept NULL for length
        if (isset($length)) {
            return \substr($str, $start, $length);
        } else {
            return \substr($str, $start);
        }
    }
}