Parse INI file into a multi-dimensional array

PHP's native parse_ini_file function allows you to process simple ini configuration files. As the documentation says "parse_ini_file() loads the ini file specified in filename, and returns the settings in it in an associative array.".

However, the problem with the function is that it will only give you an array of key => value pairs, where key will be a string and value will be a mixed value.

What if you wanted your keys themselves to be structures? For those occasions I have written the Ini_Struct class.

This class allows you to:

  • define multi-dimensional structures
  • group configurations (e.g. production, development, testing, etc.) into separate sections (native INI sections)
  • extend sections from one another
  • override keys of extended sections in extending sections

Let's look at an example.

Assume you need to have a configuration file for your database connection class where you need to define its host, user and password plus some additional options for your application. If you had to use parse_ini_file you would probably end up doing something similar to the example below:

production_database_host = 1.2.3.4
database_user = root
database_password = abcdef
production_debug = false
development_database_host = localhost
development_debug = true
;etc.

It is instantly obvious that such files are difficult to maintain. One of drawbacks in this case is that you cannot have database as one entry with host, user and password as sub-entries.

Knowing the capabilities of Ini_Struct we can rewrite our previous sample ini file into this:

[production]
database.host = 1.2.3.4
database.user = root
database.password = abcdef
debug.enabled = false

[development : production]
database.host = localhost
debug.enabled = true

[testing : development]
database.host = 5.5.5.5

As you can see, the file is much more logically structured now and as a result is much easier to "read" and maintain. In addition to structuring we also avoid duplication of data by extending development from production and testing from development. That means that development will use all values from production but will override the specified ones with its own values and testing will use all values from development.

Here follows the source code of the class. It is pretty much self-explanatory however if you have problems understanding it you can refer to the in-line comments.

  1. <?php
  2.  
  3. /**
  4.  * Allows for multi-dimensional ini files.
  5.  *
  6.  * The native parse_ini_file() function will convert the following ini file:...
  7.  *
  8.  * [production]
  9.  * localhost.database.host = 1.2.3.4
  10.  * localhost.database.user = root
  11.  * localhost.database.password = abcdef
  12.  * debug.enabled = false
  13.  *
  14.  * [development : production]
  15.  * localhost.database.host = localhost
  16.  * debug.enabled = true
  17.  *
  18.  * ...into the following array:
  19.  *
  20.  * array
  21.  *   'localhost.database.host' => 'localhost'
  22.  *   'localhost.database.user' => 'root'
  23.  *   'localhost.database.password' => 'abcdef'
  24.  *   'debug.enabled' => 1
  25.  *
  26.  * This class allows you to convert the specified ini file into a multi-dimensional
  27.  * array. In this case the structure generated will be:
  28.  *
  29.  * array
  30.  *   'localhost' =>
  31.  *     array
  32.  *       'database' =>
  33.  *         array
  34.  *           'host' => 'localhost'
  35.  *           'user' => 'root'
  36.  *           'password' => 'abcdef'
  37.  *   'debug' =>
  38.  *     array
  39.  *       'enabled' => 1
  40.  *
  41.  * As you can also see you can have sections that extend other sections (use ":" for that).
  42.  * The extendable section must be defined BEFORE the extending section or otherwise
  43.  * you will get an exception.
  44.  *
  45.  */
  46. class Ini_Struct
  47. {
  48.  
  49.     /**
  50.      * Internal storage array
  51.      *
  52.      * @var array
  53.      */
  54.     private static $_result = array();
  55.  
  56.  
  57.     /**
  58.      * Loads in the ini file specified in filename, and returns the settings in
  59.      * it as an associative multi-dimensional array
  60.      *
  61.      * @param string $filename          The filename of the ini file being parsed
  62.      * @param boolean $process_sections By setting the process_sections parameter to TRUE,
  63.      *                                  you get a multidimensional array, with the section
  64.      *                                  names and settings included. The default for
  65.      *                                  process_sections is FALSE
  66.      * @param string $section_name      Specific section name to extract upon processing
  67.      * @throws Exception
  68.      * @return array|boolean
  69.      */
  70.     public static function parse($filename, $process_sections = false, $section_name = null)
  71.     {
  72.         // load the raw ini file
  73.         $ini = parse_ini_file($filename, $process_sections);
  74.  
  75.         // fail if there was an error while processing the specified ini file
  76.         if ($ini === false) {
  77.             return false;
  78.         }
  79.  
  80.         // reset the result array
  81.         self::$_result = array();
  82.  
  83.         if ($process_sections === true) {
  84.             // loop through each section
  85.             foreach ($ini as $section => $contents) {
  86.                 // process sections contents
  87.                 self::_processSection($section, $contents);
  88.             }
  89.         } else {
  90.             // treat the whole ini file as a single section
  91.             self::$_result = self::_processSectionContents($ini);
  92.         }
  93.  
  94.         //  extract the required section if required
  95.         if ($process_sections === true) {
  96.             if ($section_name !== null) {
  97.                 // return the specified section contents if it exists
  98.                 if (isset(self::$_result[$section_name])) {
  99.                     return self::$_result[$section_name];
  100.                 } else {
  101.                     throw new Exception('Section ' . $section_name . ' not found in the ini file');
  102.                 }
  103.             }
  104.         }
  105.  
  106.         // if no specific section is required, just return the whole result
  107.         return self::$_result;
  108.     }
  109.  
  110.  
  111.     /**
  112.      * Process contents of the specified section
  113.      *
  114.      * @param string $section Section name
  115.      * @param array $contents Section contents
  116.      * @throws Exception
  117.      * @return void
  118.      */
  119.     private static function _processSection($section, array $contents)
  120.     {
  121.         // the section does not extend another section
  122.         if (stripos($section, ':') === false) {
  123.             self::$_result[$section] = self::_processSectionContents($contents);
  124.  
  125.         // section extends another section
  126.         } else {
  127.             // extract section names
  128.             list($ext_target, $ext_source) = explode(':', $section);
  129.             $ext_target = trim($ext_target);
  130.             $ext_source = trim($ext_source);
  131.  
  132.             // check if the extended section exists
  133.             if (!isset(self::$_result[$ext_source])) {
  134.                 throw new Exception('Unable to extend section ' . $ext_source . ', section not found');
  135.             }
  136.  
  137.             // process section contents
  138.             self::$_result[$ext_target] = self::_processSectionContents($contents);
  139.  
  140.             // merge the new section with the existing section values
  141.             self::$_result[$ext_target] = self::_arrayMergeRecursive(self::$_result[$ext_source], self::$_result[$ext_target]);
  142.         }
  143.     }
  144.  
  145.  
  146.     /**
  147.      * Process contents of a section
  148.      *
  149.      * @param array $contents Section contents
  150.      * @return array
  151.      */
  152.     private static function _processSectionContents(array $contents)
  153.     {
  154.         $result = array();
  155.  
  156.         // loop through each line and convert it to an array
  157.         foreach ($contents as $path => $value) {
  158.             // convert all a.b.c.d to multi-dimensional arrays
  159.             $process = self::_processContentEntry($path, $value);
  160.  
  161.             // merge the current line with all previous ones
  162.             $result = self::_arrayMergeRecursive($result, $process);
  163.         }
  164.        
  165.         return $result;
  166.     }
  167.  
  168.  
  169.     /**
  170.      * Convert a.b.c.d paths to multi-dimensional arrays
  171.      *
  172.      * @param string $path Current ini file's line's key
  173.      * @param mixed $value Current ini file's line's value
  174.      * @return array
  175.      */
  176.     private static function _processContentEntry($path, $value)
  177.     {
  178.         $pos = strpos($path, '.');
  179.  
  180.         if ($pos === false) {
  181.             return array($path => $value);
  182.         }
  183.  
  184.         $key = substr($path, 0, $pos);
  185.         $path = substr($path, $pos + 1);
  186.  
  187.         $result = array(
  188.             $key => self::_processContentEntry($path, $value),
  189.         );
  190.  
  191.         return $result;
  192.     }
  193.  
  194.  
  195.     /**
  196.      * Merge two arrays recursively overwriting the keys in the first array
  197.      * if such key already exists
  198.      *
  199.      * @param mixed $a Left array to merge right array into
  200.      * @param mixed $b Right array to merge over the left array
  201.      * @return mixed
  202.      */
  203.     private static function _arrayMergeRecursive($a, $b)
  204.     {
  205.         // merge arrays if both variables are arrays
  206.         if (is_array($a) && is_array($b)) {
  207.             // loop through each right array's entry and merge it into $a
  208.             foreach ($b as $key => $value) {
  209.                 if (isset($a[$key])) {
  210.                     $a[$key] = self::_arrayMergeRecursive($a[$key], $value);
  211.                 } else {
  212.                     if($key === 0) {
  213.                         $a= array(0 => self::_arrayMergeRecursive($a, $value));
  214.                     } else {
  215.                         $a[$key] = $value;
  216.                     }
  217.                 }
  218.             }
  219.         } else {
  220.             // one of values is not an array
  221.             $a = $b;
  222.         }
  223.  
  224.         return $a;
  225.     }
  226.  
  227. }

Let us compare the output of both functions. First, the native parse_ini_file:

array
'database.host' => string '5.5.5.5' (length=7)
'database.user' => string 'root' (length=4)
'database.password' => string 'abcdef' (length=6)
'debug.enabled' => string '1' (length=1)

As expected it is not really structured the way we would like to. Now, let's try processing it using Ini_Struct (output of var_dump with xdebug enabled):

  1. $ini = Ini_Struct::parse('config.ini', true,  'testing');
  2. var_dump($ini);

 array
'database' =>
array
'host' => string '5.5.5.5' (length=7)
'user' => string 'root' (length=4)
'password' => string 'abcdef' (length=6)
'debug' =>
array
'enabled' => string '1' (length=1)

You may notice that the host entry is 5.5.5.5. That is because we specified 'testing' as the section to extract. Therefore, first thing it did was process production. After that it did the same to development but merged its values over production's values. Last step was combining values of testing with values of development. The result is an array with all three required sections combined into one.

Zend framework users will most probably notice that the ini file is very similar to Zend_Config_Ini (in fact it's exactly the same). The only difference is speed. After performing some tests on PHP 5.3 on latest Ubuntu 10.04 with xdebug enabled the average speed for Ini_Struct was 2 milliseconds for a small file (similar to the example file) and 90 milliseconds for a pretty large 10kb file. Also this class is smaller and you don't need Zend framework to use it.

Please leave comments with feedback and suggestions!

Comments
1
Your article has a little error. the parse_ini_file is able to read sections and also multidimensional arrays with one sub array.
[database]
host = localhost
user = root
password = abcdef
is readable with the php function and you are able to use a ini file like this:
[multidimensional]
test[] = a
test[] = b
which will result in:
array 
  "multidimensional" =>
    array 
      "0" => a
      "1" => b

like shown in the php.net documention Example #1
Tim W., October 1st 2010, 12:41
2
@Tim W.: In built-in parse_ini_flle(), you cannot have an array beyond three dimensions and the third dimension cannot be associative, hence is somewhat limiting.

@ the author is this article: This looks like an excellent class and I hope to try it out.
Chaim, October 18th 2010, 18:37
3
Thanks Chaim! Was about to say the same as you.

However, correct me if I'm wrong but isn't it two two dimensions? All you can have with the default parse_ini_file is:
[section]
array[] = x
array[] = y
array[] = z
which makes it a two dimensional array.

Haven't tried array[][] but I don't think it works.
Andris, October 18th 2010, 19:41
4
The equivalent of 
array[] =x
array[] =y
array[] =z

is to put

array = x,y,z

and it will give you the same result as the built-in parse_ini_file function
john, March 26th 2011, 3:13
5
Great Work! Thank you for that. I will use this in my framework.
Daniel S, August 5th 2011, 23:45
6
@Daniel - always great to hear that my work is of use to someone. Pleasure to help!
Andris, August 6th 2011, 18:29
7
the main problem is that this class is not utf-8 ready..
use parse_ini_string(file_get_contents($path)) instead parse_ini_file($path).
also strpos and substr aren't safe, check phputf8 project
jstar, September 14th 2011, 12:55
8
thanks for the class. i have edited it and added a function for writing an array to .ini file. 
public static function write_ini($assoc_arr, $has_sections=FALSE, $file = null) { 
    $content = ""; 
    $path = is_null($file) ? self::$_file : $file;
    
    if ($has_sections) { 
        foreach ($assoc_arr as $key=>$elem) { 
            $content .= "[".$key."]\n"; 
            foreach ($elem as $key2=>$elem2) { 
                if(is_array($elem2)) 
                { 
                    $key2 .= " = ";
                    for($i=0;$i<count($elem2);$i++) 
                    { 
                        $key2 .= $elem2[$i].", "; 
                    } 
                    $content .=  substr($key2, 0, strlen($key2) - 2);
                } 
                else if($elem2=="") $content .= $key2." = \n"; 
                else $content .= $key2." = \"".$elem2."\"\n"; 
            } 
        } 
    } 
    else { 
        foreach ($assoc_arr as $key=>$elem) { 
            if(is_array($elem)) 
            { 
                for($i=0;$i<count($elem);$i++) 
                { 
                    $content .= $key."[] = \"".$elem[$i]."\"\n"; 
                } 
            } 
            else if($elem=="") $content .= $key." = \n"; 
            else $content .= $key." = \"".$elem."\"\n"; 
        } 
    } 

    if (!$handle = fopen($path, 'w')) { 
        return false; 
    } 
    if (!fwrite($handle, $content)) { 
        return false; 
    } 
    fclose($handle); 
    return true; 
}
Mawuli, November 11th 2011, 2:11
9
There is a bug in line 87 that crashes the class on files without sections when $process_sections = true.

Line 87 thus should be:

if (is_array($contents)) {
    self::_processSection($section, $contents);
}
Tom, November 30th 2011, 9:10
10
Good catch, Tom. Never even thought of testing it with mixed sections/no sections. However, there's one issue with your fix.

I tested your code with the following ini file:
x = 1

[section]
y = 2

The problem is all entries that are not under any of the sections will get ignored. To fix that we need to add an else statement to that if. However, it's not as easy as it looks at first either.

First I thought of doing something like this:
if (is_array($contents)) {
	// process sections contents
	self::_processSection($section, $contents);
} else {
	self::$_result[$section] = $contents;
}

All is fine until the point when we have a value name that is the same as the name of one of the sections. E.g.:
section = abc

[section]
value = 1

Note the section bit. If we do as I showed then the section value will get overwritten.

So far the only solution I can think of is this:
if (is_array($contents)) {
	// process sections contents
	self::_processSection($section, $contents);
} else {
	self::$_result['__ROOT'][$section] = $contents;
}

We can put all "orphaned" values into a separate array key (in this case __ROOT) or simply ensure to never have any values that don't belong to any of the sections. I know it can't always be the case but for those occasions there's the fix that I mentioned.
Andris, December 3rd 2011, 14:55
11
Nice job. I appreciate your work but it's pointless. To make your ini files in 2D arrays you only need to put 'true' for second argument: parse_ini_file('test.php', true)
yanislav, December 24th 2011, 21:43
12
What about 3D and 4D arrays? You can't do that by only passing true as the second parameter.
Andris, December 27th 2011, 16:40
13
Just wanted to thank you for the great class! This was exactly what I was looking for :)
Miah, July 18th 2012, 19:47
14
I recreated the same idea from scratch with a much cleaner approach, not using any static variables. Performance has been improved as well (by like 5% on 1000.) And it supports sectionless variables.

class Config
{
    /**
     * Simple parser for INI files with a very clean approach.
     * @param string $filename
     * @param boolean $process_sections
     * @param string $section If you want an explicit section.
     * @return array
     * @throws Exception
     */
    public static function parse($filename, $process_sections = true, $section = null)
    {
        $ini = parse_ini_file($filename, $process_sections);
        if ($ini === false) {
            throw new Exception('Unable to parse ini file.');
        }
        if (!$process_sections && $section) {
            $values = $process_sections ? $ini[$section] : $ini;
            $result = self::_processSection($values);
        } else {
            $result = array();
            foreach ($ini as $section => $values) {
                if (!is_array($values)) {
                    continue;
                }
                unset($ini[$section]);
                $expand = explode(':', $section);
                if (count($expand) == 2) {
                    $section = trim($expand[0]);
                    $source = trim($expand[1]);
                    if (!isset($result[$source])) {
                        throw new Exception("Unable to expand $section from $source");
                    }
                    $sectionResult = self::_processSection($values);
                    $result[$section] = self::_mergeRecursive($result[$source], $sectionResult);
                } else {
                    $result[$section] = self::_processSection($values);
                }
            }
            $result += $ini;
        }
        return $result;
    }

    /**
     * Process a single section with values.
     * @param array $values
     * @return array With the result.
     */
    private static function _processSection($values)
    {
        $result = array();
        foreach ($values as $key => $value) {
            $keys = explode('.', $key);
            $result = self::_recurseValue($result, $keys, $value);
        }
        return $result;
    }

    /**
     * Create the values recursively.
     * @param array $array
     * @param array $keys
     * @param mixed $value
     * @return array The original array, with changes.
     */
    private static function _recurseValue($array, $keys, $value)
    {
        $key = array_shift($keys);
        if (count($keys) > 0) {
            if (!isset($array[$key])) {
                $array[$key] = array();
            }
            $array[$key] = self::_recurseValue($array[$key], $keys, $value);
        } else {
            $array = self::_mergeValue($array, $key, $value);
        }
        return $array;
    }

    /**
     * Merge a value with the previous value.
     * @param array $array
     * @param string $key
     * @param mixed $value
     * @return array The original array, with changes.
     */
    private static function _mergeValue($array, $key, $value)
    {
        if (!isset($array[$key])) {
            $array[$key] = $value;
        } else {
            if (is_array($value)) {
                $array[$key] += $value;
            } else {
                $array[$key][] = $value;
            }
        }
        return $array;
    }

    /**
     * Recursively merge arrays, as the PHP function does not overwrite values.
     * @param type $left
     * @param type $right
     */
    private static function _mergeRecursive($left, $right) {
        // merge arrays if both variables are arrays
        if (is_array($left) && is_array($right)) {
            // loop through each right array's entry and merge it into $a
            foreach ($right as $key => $value) {
                if (isset($left[$key])) {
                    $left[$key] = self::_mergeRecursive($left[$key], $value);
                } else {
                    $left[$key] = $value;
                }
            }
        } else {
            // one of values is not an array
            $left = $right;
        }

        return $left;
    }
}
Nimja, March 25th 2013, 15:06
Name
Email (required)
will not be published
Website
Recaptcha
you will only be required to fill it in once in this session

You can use [code][/code] tags in your comments