Skip to content

About

PHP Package for handling, querying, filtering, and setting nested data structures

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

145 stars

Watchers

1 watching

Forks

Repository files navigation

PHP Data Block package

Data Block Package

Latest Version Total Downloads
Packagist License Supported PHP Versions GitHub last commit
Tests

PHP Package for Managing Nested Data

This PHP package provides classes and methods for easily managing, querying, filtering, and setting nested data structures. The PHP Data Block package offers a streamlined approach to handling nested data, whether you're working with complex JSON data, hierarchical configurations, or deeply nested arrays.

Articles about PHP DataBlock

If you want to learn more about DataBlock through real-world examples and tutorials, here are some articles that mention or use it:

What you can do with PHP Data Block

For example, with PHP Data Block, you can retrieve complex JSON from an API and then filter, sort, and handle the data. For example, here you can:

  • Retrieve the list of the repository from GitHub
  • Order the repository by the number of stars received (ascending, starting from the repos with more stars)
  • Loop the results
  • Get values from elements
<?php

use HiFolks\DataType\Block;

require './vendor/autoload.php';
$url = 'https://api.github.com/orgs/hi-folks/repos';

Block::fromJsonUrl($url)
    ->orderBy('stargazers_count', 'desc')
    ->forEach(
        function ($item) {
            echo $item->get('full_name').' : ';
            echo $item->get('stargazers_count').PHP_EOL;
        }
    );

Then, you can do more, you can:

  • Extract only the fields you need using the select() method
  • Filter the elements with the where() method and using the Operator class

Here is an example:

<?php

use HiFolks\DataType\Block;
use HiFolks\DataType\Enums\Operator;

require './vendor/autoload.php';
$url = 'https://api.github.com/orgs/hi-folks/repos';

Block::fromJsonUrl($url)
    ->select('full_name', 'stargazers_count')
    ->where('stargazers_count', Operator::GREATER_THAN, 0)
    ->orderBy('stargazers_count', 'desc')
    ->forEach(
        function ($item) {
            echo $item->get('full_name').' : ';
            echo $item->get('stargazers_count').PHP_EOL;
        }
    );

This is just an overview as an appetizer :)

Now, let's explore the classes and methods PHP Data Block provides.

One "core" element of the PHP Data Block package is the Block PHP class.

The Block class

The Block class offers comprehensive methods to create, manage, and access nested data structures.

The Block class provides various methods, including:

  • Creating structures from Arrays, JSON, and YAML files.
  • Querying nested data with ease.
  • Filtering data based on specific criteria.
  • Setting and updating values within deeply nested structures.

Installing and using the Block class

For adding to your projects, the Block class with its methods and helpers, you can run the composer require command:

composer require hi-folks/data-block

To support the development, you can "star" ⭐ the repository: https://github.com/Hi-Folks/data-block

Then, in your PHP files, you can import the HiFolks\DataType\Block Namespace:

use HiFolks\DataType\Block;

Method for creating Block objects

To show the capabilities of the following methods, I will use this nested associative array:

$fruitsArray = [
    "avocado" =>
    [
        'name' => 'Avocado',
        'fruit' => '🥑',
        'wikipedia' => 'https://en.wikipedia.org/wiki/Avocado',
        'color'=>'green',
        'rating' => 8
    ],
    "apple" =>
    [
        'name' => 'Apple',
        'fruit' => '🍎',
        'wikipedia' => 'https://en.wikipedia.org/wiki/Apple',
        'color' => 'red',
        'rating' => 7
    ],
    "banana" =>
    [
        'name' => 'Banana',
        'fruit' => '🍌',
        'wikipedia' => 'https://en.wikipedia.org/wiki/Banana',
        'color' => 'yellow',
        'rating' => 8.5
    ],
    "cherry" =>
    [
        'name' => 'Cherry',
        'fruit' => '🍒',
        'wikipedia' => 'https://en.wikipedia.org/wiki/Cherry',
        'color' => 'red',
        'rating' => 9
    ],
];

The static make() method

With the static make() method, you can generate a Block object from an associative array:

$data = Block::make($fruitsArray);

The $data object is an instance of the Block class.

In the case you want to initialize an empty Block object, you can call the make() method with no parameters:

$data = Block::make();

Once you initialize the Block object, you can use its methods.

The get() method

The get() method supports keys/indexes with the dot (or custom) notation for retrieving values from nested arrays. It returns the original type of data. If you need to obtain a Block object, you should use the getBlock() method instead of get(). For example:

$data->get('avocado'); // returns an array
$data->get('avocado.color'); // returns the string "green"

For example, with the $fruitsArray sample data, the $data->get("avocado") is:

  • an array;
  • has five elements;

For example, the $data->get("avocado.color") is:

  • a string;
  • has the value "green";

The $data->get("avocado.rating") is:

  • a numeric;
  • specifically an integer;

The $data->get("banana.rating") is:

  • a numeric;
  • specifically a float;

You can customize the notation with a different character:

$data->get('apple#fruit', charNestedKey: '#'); // 🍎

If you are going to access a not valid key, a null value is returned:

$value = $data->get('apple.notexists'); // null

You can define a default value in the case the key doesn't exist:

$value = $data->get(
    'apple.notexists',
    '🫠'
); // 🫠

And you can combine the default value and the nested character:

$value = $data->get(
    'apple#notexists',
    '🫠',
    '#'
); // 🫠

Parsing and formatting dates

Use getDate() when you need a nullable DateTimeImmutable. Supply the known input format for tabular exports so parsing is unambiguous:

$closeDate = $row->getDate(
    'Close Date',
    inputFormat: '!d/m/Y',
);

echo $closeDate?->format('Y-m-d');

The ! is part of PHP's date format syntax and resets fields not present in the input, such as the time, to a predictable value. When inputFormat is omitted, PHP's normal date parser is used, which is convenient for ISO values:

$createdAt = $row->getDate('created_at');

Use requireDate() when missing, empty, or invalid input must stop processing:

$closeDate = $row->requireDate(
    'Close Date',
    inputFormat: '!d/m/Y',
);

For a nullable formatted string in one step, use getFormattedDate():

$normalized = $row->getFormattedDate(
    'Close Date',
    inputFormat: '!d/m/Y',
    outputFormat: 'Y-m-d',
);
// '2026-09-20' or null
Need Method Result
Parse an optional value getDate() ?DateTimeImmutable
Require a valid value requireDate() DateTimeImmutable or exception
Parse and format an optional value getFormattedDate() ?string

All three methods support nested paths, custom key separators, and an optional DateTimeZone. Explicit-format parsing checks both PHP parser errors and warnings, so impossible calendar dates such as 31/02/2026 are rejected. It never falls back to automatic parsing when the explicit format does not match.

getFormattedDateTime() is deprecated as of 2.1.0. Existing calls continue to work, but new code should use getFormattedDate() for nullable formatting or requireDate()->format() when invalid input must throw.

Required typed accessors

Use the require*() accessors when a field is mandatory and invalid input must stop processing. They are especially useful at import and API boundaries, where silently turning malformed data into 0, false, or an empty string can hide a data-quality problem:

$name = $row->requireString('name');
$quantity = $row->requireInt('quantity');
$amount = $row->requireFloat('amount');
$enabled = $row->requireBoolean('enabled');

The existing nullable and Strict getters remain useful when fallback or coercion is intentional. Despite their historical name, methods such as getIntStrict() guarantee a non-null return type; they do not validate that the source value is a well-formed integer.

Need Example Behavior
Optional value getInt('quantity') Returns an integer or null
Guaranteed value with fallback getIntStrict('quantity') Coerces the value or returns 0
Mandatory valid value requireInt('quantity') Returns an integer or throws

All required accessors throw OutOfBoundsException for a missing field and UnexpectedValueException for null or invalid data. They support nested paths and custom separators in the same way as get().

Their validation rules are deliberately explicit:

  • requireString() accepts only non-blank strings. It does not turn integers or booleans into strings.
  • requireInt() accepts integers, integral floats, and integer-formatted strings such as "42" or "0042". It rejects fractional and out-of-range values instead of silently truncating them.
  • requireFloat() accepts finite integers, floats, and numeric strings. It rejects booleans, non-numeric strings, infinity, and NaN.
  • requireBoolean() accepts booleans, integers 1 and 0, and the strings true, false, 1, 0, yes, no, on, and off. String matching is case-insensitive and ignores surrounding whitespace.

For example, this is appropriate while normalizing imported CSV rows:

$rows = Block::fromCsvFile(
    'orders.csv',
    normalize: fn (Block $row): array => [
        'reference' => $row->requireString('reference'),
        'quantity' => $row->requireInt('quantity'),
        'amount' => $row->requireFloat('amount'),
        'paid' => $row->requireBoolean('paid'),
    ],
);

If malformed values should instead be replaced with application defaults, use the existing getters rather than the required accessors.

The getFormattedByte() for getting and formatting a 'byte' field value

The getFormattedByte() method retrieves and formats a byte value from a specified field in a data block. It converts the raw byte count into a more human-readable format (e.g., GB, MB, etc.), with an optional precision parameter to control the number of decimal places displayed.

Parameters:

  • $path (string): The path to the field containing the byte value (e.g., "assets.0.total_bytes").
  • $precision (int): (Optional) Number of decimal places to include in the formatted result. The default is 2.

Example usage:

$data1->getFormattedByte("assets.0.total_bytes");  // Returns "5.98 GB"
$data1->getFormattedByte("assets.1.total_bytes");  // Returns "2.18 GB"
$data1->getFormattedByte("assets.1.total_bytes", 5);  // Returns "2.18288 GB"
$data1->getFormattedByte("assets.1.total_bytes", 0);  // Returns "2 GB"

Key Features:

  • Automatic unit conversion: converts bytes into appropriate units (e.g., KB, MB, GB) based on the size.
  • Customizable precision: you can specify the number of decimal places for the output, making it flexible for various use cases.

The getString() method

The getString() method retrieves the value of a specified field as a string from a data block. If the field does not exist or is null, it returns a default value, which can be customized.

Parameters:

  • $path (string): The path to the field (e.g., "0.commit.author.date").
  • $default (string|null): (Optional) The default value to return if the field doesn't exist. Defaults to null.

Example Usage:

$data1->getString("0.commit.author.date");  // Returns the field value as a string
$data1->getString("0.commit.author.notexists");  // Returns null
$data1->getString("0.commit.author.notexists", "AA");  // Returns "AA"
$data1->getString("0.commit.comment_count");  // Returns "0" as a string even if the field value is an integer

The getStringStrict() method

The getStringStrict() method retrieves the value of a specified field as a string from a data block. If the field does not exist or is null, it returns a default string value, which can be customized ("" by default).

Parameters:

  • $path (string): The path to the field (e.g., "0.commit.author.date").
  • $default (string): (Optional) The default value to return if the field doesn't exist. Defaults to "".

Example Usage:

$data1->getStringStrict("0.commit.author.date");  // Returns the field value as a string
$data1->getStringStrict("0.commit.author.notexists");  // Returns an empty string ""
$data1->getStringStrict("0.commit.author.notexists", "AA");  // Returns "AA"
$data1->getStringStrict("0.commit.comment_count");  // Returns "0" as a string even if the field value is an integer

The getInt() method

The getInt() method retrieves the value of a specified field as a integer from a data block. If the field does not exist or is null, it returns a default value, which can be customized (null by default). Parameters:

  • $path (string): The path to the field (e.g., "0.author.id").
  • $default (null|int): (Optional) The default value to return if the field doesn't exist. Defaults to null.
  • $charNestedKey (string): the character separator for nested field names. The default is ".".

Example usage:

$data1->getInt("0.author.id"); // Returns the field value as an integer, for example 678434
$data1->getInt("0.author.idx"); // Returns null because the field doesn't exists
$data1->getInt("0.author.idx", 44); // Returns 44 because the field doesn't exists, and you set a default, in this case 44

The getIntStrict() method

The getIntStrict() method retrieves the value of a specified field as a integer from a data block. If the field does not exist or is null, it returns a default value, which can be customized (0 by default). Parameters:

  • $path (string): The path to the field (e.g., "0.author.id").
  • $default (int): (Optional) The default value to return if the field doesn't exist. Defaults to 0.
  • $charNestedKey (string): the character separator for nested field names. The default is ".".

Example usage:

$data1->getIntStrict("0.author.id"); // Returns the field value as an integer, for example 678434
$data1->getIntStrict("0.author.idx"); // Returns 0 because the field doesn't exists, and the method is strict
$data1->getIntStrict("0.author.idx", 44); // Returns 44 because the field doesn't exists, and you set a default, in this case 44

The getFloat() method

The getFloat() method retrieves the value of a specified field as a float from a data block. If the field does not exist or is null, it returns a default value, which can be customized (null by default).

Parameters:

  • $path (string): The path to the field (e.g., "0.author.score").
  • $default (null|float): (Optional) The default value to return if the field doesn't exist. Defaults to null.
  • $charNestedKey (string): The character separator for nested field names. The default is ".".

Example usage:

$data1->getFloat("0.author.score"); // Returns the field value as a float, for example 4.75
$data1->getFloat("0.author.notexists"); // Returns null because the field doesn't exist
$data1->getFloat("0.author.notexists", 1.5); // Returns 1.5 because the field doesn't exist, and you set a default

The getFloatStrict() method

The getFloatStrict() method retrieves the value of a specified field as a float from a data block. If the field does not exist or is null, it returns a default value, which can be customized (0.0 by default).

Parameters:

  • $path (string): The path to the field (e.g., "0.author.score").
  • $default (float): (Optional) The default value to return if the field doesn't exist. Defaults to 0.0.
  • $charNestedKey (string): The character separator for nested field names. The default is ".".

Example usage:

$data1->getFloatStrict("0.author.score"); // Returns the field value as a float, for example 4.75
$data1->getFloatStrict("0.author.notexists"); // Returns 0.0 because the field doesn't exist, and the method is strict
$data1->getFloatStrict("0.author.notexists", 1.5); // Returns 1.5 because the field doesn't exist, and you set a default

The getBoolean() method

The getBoolean() method retrieves the value of a specified field as a boolean from a data block. If the field does not exist or is null, it returns a default value, which can be customized (null by default). Parameters:

  • $path (string): The path to the field (e.g., "0.author.id").
  • $default (null|bool): (Optional) The default value to return if the field doesn't exist. Defaults to null.
  • $charNestedKey (string): the character separator for nested field names. The default is ".".

Example usage:

$data1->getBoolean("0.author.site_admin"); // Returns the field value as an boolean, for example true
$data1->getBoolean("0.author.site_admin_notexists"); // Returns null because the field doesn't exists
$data1->getBoolean("0.author.site_admin_notexists", true); // Returns true because the field doesn't exists, and you set a default, in this case true

The getBooleanStrict() method

The getBooleanStrict() method retrieves the value of a specified field as a boolean from a data block. If the field does not exist or is null, it returns a strict boolean default value, which can be customized (false by default). Parameters:

  • $path (string): The path to the field (e.g., "0.author.id").
  • $default (bool): (Optional) The default value to return if the field doesn't exist. Defaults to false.
  • $charNestedKey (string): the character separator for nested field names. The default is ".".

Example usage:

$data1->getBooleanStrict("0.author.site_admin"); // Returns the field value as an boolean, for example true
$data1->getBooleanStrict("0.author.site_admin_notexists"); // Returns false because the field doesn't exists
$data1->getBooleanStrict("0.author.site_admin_notexists", true); // Returns true because the field doesn't exists, and you set a default, in this case true

The getBlock() method

If you need to manage a complex array (nested array) or an array obtained from a complex JSON structure, you can access a portion of the array and obtain the Block object via the getBlock() method.

Let's see an example:

$appleData = $data->getBlock("apple")
// $data is the Block instance so that you can access
// to the Block methods like count()
$data->getBlock("apple")->count();

If the element accessed via getBlock() is a scalar type (integer, float, string, etc.), a Block object (with just one element) will be returned using getBlock().

For example, $data->getBlock("avocado") returns a Block object with five elements.

For example, $data->getBlock("avocado.color") returns a Block object with just one element.

If you are going to access a non-valid key, an empty Block object is returned, so the $data->getBlock("avocado.notexists") returns a Block object with a length equal to 0.

Missing key behavior

By default, accessing a non-existing key returns the provided default value silently.

You can configure three behaviors:

  • Silent (default)
  • Warning (non-fatal)
  • Exception

Silent (default)

$fruits = Block::make($fruitsArray);
// OR if you want to be more explicit: $fruits = Block::make($fruitsArray)->silentOnMissingKey();

$nothing = $fruits->get("a-missing-key", "DEFAULT VALUE"); // no warning, no exception

Warning

$fruits = Block::make($fruitsArray)->warnOnMissingKey();

$nothing = $fruits->get("a-missing-key", "DEFAULT VALUE"); // PHP warning

Exception

$fruits = Block::make($fruitsArray)
    ->throwOnMissingKey();

$nothing = $fruits->get("a-missing-key"); // throws exception

You can also pass your own exception class (must extend \Throwable).

$fruits->throwOnMissingKey(
    MyMissingKeyException::class,
    "The key in the JSON configuration file does not exist?"
);
$nothing = $fruits->get("a-missing-key"); // throws your custom exception

Summary for the "missing key behavior"

Mode Result
Silent (default behavior) Returns default value
Warning Emits warning, returns default value
Exception Throws exception

The set() method

The set() method supports keys with the dot (or custom) notation for setting values for nested data. If a key doesn't exist, the set() method creates one and sets the value. If a key already exists, the set() method will replace the value related to the key.

Parameters

  • key (int|string): The key to which the value should be assigned. If a string is provided, you can use dot notation to set nested values.
  • value (mixed): The value to be assigned to the specified key.
  • charNestedKey (string, optional): The character used for dot notation in nested keys. Defaults to ..

Returns

  • self: Returns the instance of the class for method chaining.

Example Usage

$articleText = "Some words as a sample sentence";
$textField = Block::make();
$textField->set("type", "doc");
$textField->set("content.0.content.0.text", $articleText);
$textField->set("content.0.content.0.type", "text");
$textField->set("content.0.type", "paragraph");

So when you try to set a nested key as "content.0.content.0.text", it will be created elements as a nested array.

Once you set the values, you can access them via get() (or getBlock()) methods:

$textField->get("content.0.content.0.text");

Extracting Keys

Via the keys() method, you can retrieve the list of the keys:

$data = Block::make($fruitsArray);
$keys = $data->keys();
/*
Array
(
    [0] => avocado
    [1] => apple
    [2] => banana
    [3] => cherry
)
*/

You can retrieve the keys of a nested element, combining the usage of getBlock() and the keys():

$data = Block::make($fruitsArray);
$keys = $data->getBlock("avocado")->keys();

/*
Array
(
    [0] => name
    [1] => fruit
    [2] => wikipedia
    [3] => color
    [4] => rating
)
*/

Exporting data

Exporting to array with toArray()

The toArray() method can access the native array (associative and nested).

This is helpful when manipulating data with the Block class, and at a certain point, you need to send the data to your function or a function from a third-party package that expects to receive a native array as a parameter.

$file = "./composer.json";
$composerContent = Block::fromJsonFile($file);
// here you can manage $composerContent with Block methods
// end then exports the Block data into a native array
$array = $composerContent->toArray();

Exporting to JSON string with toJson()

If you need to generate a valid JSON string using the content of the Block object, you can use the toJson() method.

This is helpful when you are manipulating data with the Block class and at a certain point need to send the data in JSON string format to your own function or a function from a third-party package that expects to receive a JSON string as a parameter.

$data = Block::make($fruitsArray);
$jsonString = $data->toJson(); // JSON string with "pretty print"

Pretty printing remains the default for backward compatibility. You can pass any native json_encode() flags and configure the maximum encoding depth:

$jsonString = $data->toJson(
    flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE,
    depth: 512,
);

By default, an encoding error returns false, matching json_encode(). At validation boundaries, add JSON_THROW_ON_ERROR so malformed data cannot be mistaken for valid output:

$jsonString = $data->toJson(
    flags: JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
);

This throws JsonException with the encoding error. Supplying flags replaces the default, so include JSON_PRETTY_PRINT explicitly when formatted output is still desired. The depth must be greater than zero; an invalid depth throws ValueError.

Exporting to YAML string with toYaml()

If you need to generate a valid YAML string using the content of the Block object, you can use the toYaml() method.

This is helpful when manipulating data with the Block class and, at a certain point, need to send the data in YAML string format to your function or a function from a third-party package that expects to receive a YAML string as a parameter.

$data = Block::make($fruitsArray);
$yamlString = $data->toYaml(); // YAML string

Saving JSON to a file with saveToJson()

If you need to save the JSON string in a file using the content of the Block object, you can use the saveToJson() method.

This is helpful when you are manipulating data with the Block class and at a certain point need to save the data in JSON string format to a file. The first two parameters preserve the original API:

  • filename: the first parameter (mandatory) with the filename;
  • overwrite: the second parameter (optional), If the file exists, the file is not saved by default, unless you set the overwrite parameter as true.
$data = Block::make($fruitsArray);
$jsonString = $data->saveToJson('./fruits.json', true);

Encoding flags and depth can also be supplied:

$saved = $data->saveToJson(
    './fruits.json',
    overwrite: true,
    flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
    depth: 512,
);

The method returns false when overwrite is disabled for an existing file, when JSON encoding returns false, or when the file cannot be written. With JSON_THROW_ON_ERROR, encoding failures throw JsonException; filesystem failures still return false.

Loading Data

Loading Data from JSON file

$file = "./composer.json";
$composerContent = Block::fromJsonFile($file);
echo $composerContent->get("name"); // for example: "hi-folks/data-block"
echo $composerContent->get("authors.0.name"); // for example: "Roberto B."

Loading Data from JSON URL

You can build your Block data from a remote JSON (like an API). For example, you can use the fromJsonUrl() method to build a Block object from the latest commits via GitHub API. Retrieving JSON API into a Block object is useful for applying the methods provided by the Block class, for example, filtering the data. In the example, I'm going to filter the commit based on the name of the author of the commit:

$url = "https://api.github.com/repos/hi-folks/data-block/commits";
$commits = Block::fromJsonUrl($url);
$myCommits = $commits->where("commit.author.name", Operator::LIKE, "Roberto");
foreach ($myCommits as $value) {
    echo $value->get("commit.message") . PHP_EOL;
}

Loading Data from YAML file

$file = "./.github/workflows/run-tests.yml";
$workflow = Block::fromYamlFile($file);
echo $workflow->get("name"); // Name of the GitHub Action Workflow
echo $workflow->get("jobs.test.runs-on");
echo $workflow->get("on.0"); // push , the first event

Loading CSV files

CSV loading supports three memory strategies. Choose the one that matches the file size and the operation you need to perform.

Goal Method Memory behavior
Use the complete file as one Block fromCsvFile() Loads every row into memory
Process one row at a time streamCsvFile() Keeps only the current row in memory
Use Block operations on bounded batches chunkCsvFile() Keeps one configurable chunk in memory

Eager loading

Use fromCsvFile() for files that comfortably fit in memory:

$opportunities = Block::fromCsvFile(
    '/exports/opportunities.csv',
    encoding: 'Windows-1252',
);

$total = $opportunities
    ->whereNotNull('Amount')
    ->sum('Amount');

The first row is used as field names by default. Quoted delimiters and quoted multiline fields are parsed correctly.

Row-by-row streaming

Use streamCsvFile() for very large Salesforce or reporting exports. It returns a Generator of Block rows and does not materialize the complete file:

foreach (Block::streamCsvFile(
    '/exports/opportunities.csv',
    encoding: 'Windows-1252',
) as $opportunity) {
    if ($opportunity->getFloatStrict('Amount') > 0) {
        // Process or persist this row before reading the next one.
    }
}

Iteration is lazy: the file is opened and rows are parsed only as they are requested. The file handle is closed when iteration finishes or when the generator is released after stopping early.

Processing chunks

Use chunkCsvFile() when you need existing collection methods without holding the entire export in memory:

$total = 0;

foreach (Block::chunkCsvFile(
    '/exports/opportunities.csv',
    chunkSize: 1_000,
    encoding: 'Windows-1252',
) as $opportunities) {
    $total += $opportunities
        ->whereNotNull('Amount')
        ->sum('Amount');
}

Filtering, mapping, validation, counting, and summing are safe to perform per chunk. Operations requiring the complete dataset—such as a global orderBy(), top-N query, or complete grouping—must not be calculated independently per chunk. For averages, combine the total sum and item count across chunks.

CSV options and validation

All three methods support the same parsing options:

Option Default Purpose
delimiter , Field separator
enclosure " Quoted-field character
escape Empty Explicit RFC 4180-oriented escape behavior
encoding UTF-8 Source encoding converted to UTF-8
header true Use the first non-empty row as field names
skipEmptyRows true Ignore physically empty CSV records
rowWidth CsvRowWidth::STRICT Handle rows whose field count differs from the header
normalize null Optionally transform each row while it is read
requiredHeaders [] Require specific case-sensitive header names while allowing additional columns

Header names must be present and unique. UTF-8 BOM bytes are removed from the first header automatically.

Use requiredHeaders to detect an unexpected export schema before processing data. Every missing header is reported in one exception, additional columns are allowed, and header-only files are supported:

$opportunities = Block::fromCsvFile(
    '/exports/opportunities.csv',
    requiredHeaders: [
        'Opportunity Name',
        'Stage',
        'Amount',
    ],
);

Required names are matched strictly and case-sensitively after source-encoding conversion and UTF-8 BOM removal. requiredHeaders cannot be combined with header: false because positional CSV records have no names to validate.

The same option works with eager, streaming, and chunked loading. Generators are lazy, so streamCsvFile() and chunkCsvFile() open the file and validate its headers when iteration begins. Validation always completes before the first row or chunk is yielded.

Choose an explicit row-width policy:

use HiFolks\DataType\Enums\CsvRowWidth;

CsvRowWidth::STRICT; // Throw with the CSV record number on any mismatch.
CsvRowWidth::PAD;    // Pad short rows with null; reject rows with extra fields.
CsvRowWidth::SKIP;   // Skip rows whose width does not match the header.

CSV values remain strings by default. This avoids corrupting identifiers, leading zeros, dates, and large numbers through automatic type guessing. Use normalize when explicit conversion is appropriate:

$opportunities = Block::fromCsvFile(
    '/exports/opportunities.csv',
    normalize: fn(Block $row): array => [
        ...$row->toArray(),
        'Amount' => $row->getFloatStrict('Amount'),
        'IsClosed' => $row->getStringStrict('IsClosed') === 'true',
    ],
);

Loading Data from JSON URL via Symfony HttpClient

If you want more control over the HTTP request (headers, authentication, timeouts, retries, etc.) or your environment restricts PHP stream functions (for example allow_url_fopen=0), you can use fromHttpJsonUrl().
This method relies on your Symfony HttpClient implementation and allows you to pass any request options supported by the client.

use HiFolks\DataType\Block;
use HiFolks\DataType\Enums\Operator;
use Symfony\Component\HttpClient\HttpClient;

$url = "https://api.github.com/repos/hi-folks/data-block/commits";
$client = HttpClient::create();

$commits = Block::fromHttpJsonUrl($url, $client, [
    'headers' => [
        'User-Agent' => 'my-app',
        'Accept' => 'application/json',
    ],
]);

$myCommits = $commits->where("commit.author.name", Operator::LIKE, "Roberto");
foreach ($myCommits as $value) {
    echo $value->get("commit.message") . PHP_EOL;
}

Don't forget to install the client's implementation composer require symfony/http-client

Adding and appending elements

Appending the elements of a Block object to another Block object

If you have a Block object, you can add elements from another Block object. One use case is if you have multiple JSON files and want to retrieve paginated content from an API. In this case, you want to create one Block object with all the elements from every JSON file.

$data1 = Block::fromJsonFile("./data/commits-10-p1.json");
$data2 = Block::fromJsonFile("./data/commits-10-p2.json");
$data1->count(); // 10
$data2->count(); // 10
$data1->append($data2);
$data1->count(); // 20
$data2->count(); // 10

Appending the elements of an array to a Block object

If you have an array, you can add elements to a Block object. Under the hood, a Block object is an array (that potentially can be a nested array). Appending an array will add elements at the root level:

$data1 = Block::make(["a","b"]);
$arrayData2 = ["c","d"];
$data1->count(); // 2
$data1->append($arrayData2);
$data1->count(); // 4

Appending an element

If you need to append an element as a single element (even if it is an array or a Block object), you can use the appendItem() function:

$data1 = Block::make(["a", "b"]);
$arrayData2 = ["c", "d"];
$data1->appendItem($arrayData2);
$data1->count(); // 3 because a, b, and the whole array c,d as single element
$data1->toArray();
/*
[
    'a',
    'b',
    [
        'c',
        'd',
    ],
]
*/

Querying, sorting data

The where() method

You can filter data elements for a specific key with a specific value. You can also set the operator

$composerContent = Block::fromJsonString($jsonString);
$banners = $composerContent->getBlock("story.content.body")->where(
    "component",
    Operator::EQUAL,
    "banner",
);

With the where() method, the filtered data keeps the original keys. If you want to avoid preserving the keys and set new integer keys starting from 0, you can set the fourth parameter (preserveKeys) as false.

    $composerContent = Block::fromJsonString($jsonString);
    $banners = $composerContent->getBlock("story.content.body")->where(
        "component",
        Operator::NOT_EQUAL,
        "banner",
+        false
    );

With where() method you can use different operators, like "==", ">", "<" etc.

You can use also the has operator in the case your nested data contains arrays or in operator in the case you want to check if your data field value is included in an array of elements.

The operators

The Operator class provides a set of predefined constants that represent comparison and logical operators. This ensures type safety and prevents errors from using invalid or misspelled operators in your data comparisons.

Supported Operators:

  • Operator::EQUAL (==)
  • Operator::STRICT_EQUAL (===)
  • Operator::GREATER_THAN (>)
  • Operator::LESS_THAN (<)
  • Operator::GREATER_THAN_OR_EQUAL (>=)
  • Operator::LESS_THAN_OR_EQUAL (<=)
  • Operator::NOT_EQUAL (!=)
  • Operator::STRICT_NOT_EQUAL (!==)
  • Operator::IN (array inclusion)
  • Operator::HAS (array containment)
  • Operator::LIKE (string contains)

The Operator class is defined in the use HiFolks\DataType\Enums\Operator namespace.

Unknown operators throw InvalidArgumentException; they never fall back to another comparison. Operator::EQUAL and Operator::NOT_EQUAL intentionally use PHP's loose comparison rules. Use Operator::STRICT_EQUAL and Operator::STRICT_NOT_EQUAL when values must also have the same type.

Missing, null, and incompatible values

Query behavior is designed to avoid accidental matches from PHP type coercion:

Condition Missing field Explicit null Incompatible value
Equality operators Never matches Compared using the selected loose or strict operator Compared using the selected operator
<, <=, >, >= Never matches Never matches Never matches
IN Never matches Matches only when null is in the list Uses strict membership
HAS Never matches Never matches Never matches unless the field is an array or Block
LIKE Never matches Never matches Never matches unless both values are scalar

Relational comparisons accept non-empty strings, integers, and floats. This means consistently formatted date strings such as ISO YYYY-MM-DD dates can be compared, while missing, null, empty-string, boolean, array, and object values are excluded.

Null, range, and inclusion helpers

Use whereNull() when a field is missing or explicitly null. It does not match 0, false, or an empty string:

$withoutAmount = $rows->whereNull('amount');
$withAmount = $rows->whereNotNull('amount');

whereBetween() performs an inclusive range comparison and supports nested fields. Missing, null, empty, and incompatible values are excluded:

$closingThisQuarter = $rows->whereBetween(
    'close_date',
    '2026-01-01',
    '2026-03-31',
);

whereIn() provides a readable inclusion query and uses strict comparison by default, preventing values such as 0, "0", and false from matching each other:

$activeStages = $rows->whereIn(
    'stage',
    ['Proposal', 'Negotiation'],
);

Use whereNotIn() for the inverse operation: retain rows whose existing field value is not in an exclusion list. This communicates an exclusion rule more directly than filtering and manually negating in_array().

Given this input:

$orders = Block::make([
    'first' => ['status' => 'active'],
    'second' => ['status' => 'cancelled'],
    'third' => ['status' => 'pending'],
    'fourth' => ['status' => 'deleted'],
    'fifth' => ['reference' => 'missing-status'],
]);

$openOrders = $orders->whereNotIn(
    'status',
    ['cancelled', 'deleted'],
);

The result preserves the original row keys and excludes the row with no status field:

[
    'first' => ['status' => 'active'],
    'third' => ['status' => 'pending'],
]

Like whereIn(), comparison is strict by default. Thus 0, "0", and false remain distinct. Pass strict: false only when PHP's loose membership comparison is intentional. Pass preserveKeys: false to produce a zero-based result. An explicit null is treated as an existing value: it is excluded only when null appears in the exclusion list. An empty exclusion list returns all rows that contain the selected field; rows missing the field are still skipped.

All query helpers preserve keys by default; pass preserveKeys: false to return a zero-based result.

The in operator

The in operator is used within the where method to filter elements from a data collection based on whether a specific field's value exists within a given array of values. The behavior is as follows:

$data->where("field", Operator::IN, ["value1", "value2", ...])

If the field's value exists in the provided array with the same value and type, the element is included in the result. Example: Filtering fruits by color that match either "green" or "black"

$greenOrBlack = $data->where("color", Operator::IN, ["green", "black"]);

You should use the in operator if your field is a scalar type (for example string or number) and you need to check if it is included in a list of values (array).

The has operator

The has operator is used within the where method to filter elements from a data collection based on whether a specific field contains a given value, typically in cases where the field holds an array or a collection of tags or attributes. The behavior is as follows:

$data->where("field", Operator::HAS, "value")

For example if you have posts and each post can have multiple tags, you can filter posts with a specific tag:

$url = "https://dummyjson.com/posts";
$posts = Block
    ::fromJsonUrl($url)
    ->getBlock("posts");

$lovePosts = $posts->where("tags", Operator::HAS, "love");

Summary in VS has

The in operator filters elements by matching a field's value against an array of possible values. If the value exists in the array, the element is included in the result. An empty array returns no results.

The has operator filters elements by checking if a specific value exists within a field (usually an array or a collection). If the value exists, the element is included in the result. Non-existent values return no matches.

The extractWhere() method

The extractWhere() method allows you to recursively query data elements and extract all elements that match a given property/value pair.

It is especially useful when working with deeply nested data structures (for example JSON content trees), where matching items may appear at any depth.

The implementation:

  • Recursively scans the entire Block
  • Finds all elements that:
    • Contain the given $property
    • Have a value strictly equal (===) to $value
  • Returns a new Block instance containing only the matched items
  • The original datablock is not modified
$jsonString = file_get_contents('./tests/data/story.json');

$story = Block::fromJsonString($jsonString);

// Extract all items where "fieldtype" === "asset"
$assets = $story->extractWhere('fieldtype', 'asset');

// Debug output
$assets->dump();

The orderBy() method

Use orderBy() with a SortCriterion to sort data by one field. The named asc() and desc() constructors make the direction explicit, provide good IDE autocomplete, and prevent invalid directions.

use HiFolks\DataType\SortCriterion;

$sorted = $orders->orderBy(SortCriterion::desc('totals.amount'));

For example, if you want to retrieve the data at story.content.body key and sort them by component key:

$composerContent = Block::fromJsonString($jsonString);
$bodyComponents = $composerContent
    ->getBlock('story.content.body')
    ->orderBy(SortCriterion::asc('component'));

You can also order data for a nested attribute. Consider retrieving a remote JSON like the dummy JSON posts and then ordering the posts via the reactions.likes nested field in descending order:

use HiFolks\DataType\Block;
use HiFolks\DataType\SortCriterion;

$posts = Block
    ::fromJsonUrl("https://dummyjson.com/posts")
    ->getBlock("posts");
echo $posts->count(); // 30
$mostLikedPosts = $posts->orderBy(
    SortCriterion::desc('reactions.likes'),
);
$mostLikedPosts->dump();

Sorting is stable: rows with the same value keep their original relative order. Existing keys are also preserved, which is especially useful when keys are IDs. Chain values() when you need a zero-based list instead:

$sorted = $orders->orderBy(SortCriterion::desc('totals.amount'));
$sortedAndReindexed = $sorted->values();

The earlier syntax remains supported for backward compatibility and for directions coming from configuration. String directions are case-insensitive and invalid values throw an InvalidArgumentException:

use HiFolks\DataType\Enums\SortDirection;

$orders->orderBy('totals.amount', SortDirection::DESC);
$orders->orderBy('totals.amount', 'DESC');

Missing and null field values are placed last for both ascending and descending order. Arrays and objects are not directly comparable, so they are also placed last. This makes incomplete or unexpectedly structured rows behave predictably rather than affecting the useful sorted values.

The orderByMany() method

Use orderByMany() when the next field should break ties in the previous one. Criteria are applied from left to right and nested fields are supported:

use HiFolks\DataType\SortCriterion;

$report = $opportunities->orderByMany([
    SortCriterion::asc('Assigned Solution Engineer'),
    SortCriterion::desc('_created_date'),
]);

orderByMany() accepts a list of SortCriterion objects, giving single-field and multi-field sorting the same API. An empty list returns an unchanged copy.

The sort() method

Use sort() when ordering depends on a custom business rule rather than field directions:

$sorted = $opportunities->sort(
    fn (Block $a, Block $b): int =>
        ($a->get('probability') * $a->get('amount'))
        <=> ($b->get('probability') * $b->get('amount')),
);

The comparator must return an integer below, equal to, or above zero. It receives Block rows by default; when iteration is configured with Block::make($rows, false) or iterateBlock(false), it receives the raw array rows instead. Like orderBy(), callback sorting is stable, preserves keys, and does not modify the original Block.

Need Method
Sort by one field orderBy(SortCriterion::desc('amount'))
Sort by several fields orderByMany([SortCriterion::asc('team'), SortCriterion::desc('amount')])
Apply a custom comparison rule sort(fn (Block $a, Block $b): int => ...)
Reindex sorted results ->values()

These methods sort an in-memory Block. For very large CSV exports, stream or chunk the input first and avoid a global sort unless the selected data fits in memory.

The select() method

The select() method allows you to select only the needed fields. You can list the field names you need as parameters for the select() method. For example:

use HiFolks\DataType\Block;
$dataTable = [
    ['product' => 'Desk', 'price' => 200, 'active' => true],
    ['product' => 'Chair', 'price' => 100, 'active' => true],
    ['product' => 'Door', 'price' => 300, 'active' => false],
    ['product' => 'Bookcase', 'price' => 150, 'active' => true],
    ['product' => 'Door', 'price' => 100, 'active' => true],
];
$table = Block::make($dataTable);
$data = $table
    ->select('product' , 'price');
print_r($data->toArray());

You can combine the select(), the where(), and the orderBy() method. If you want to retrieve elements with product and price keys, with a price greater than 100 and ordered by price:

$table = Block::make($dataTable);
$data = $table
    ->select('product' , 'price')
    ->where('price', Operator::GREATER_THAN, 100)
    ->orderBy("price");
print_r($data->toArray());
/*
Array
(
    [0] => Array
        (
            [product] => Bookcase
            [price] => 150
        )

    [1] => Array
        (
            [product] => Desk
            [price] => 200
        )

    [2] => Array
        (
            [product] => Door
            [price] => 300
        )

)
*/

Adding computed columns with withColumn() and withColumns()

Use computed columns when tabular rows need derived values but the complete row should otherwise remain unchanged. This avoids reconstructing every row in a map() callback:

$ordersWithTotals = $orders->withColumn(
    'total',
    fn (Block $row): float => $row->requireInt('quantity')
        * $row->requireFloat('unit_price'),
);

Add several derived columns in one operation with withColumns(). Callbacks receive the original row as a Block and its key:

$report = $orders->withColumns([
    'total' => fn (Block $row): float => $row->requireInt('quantity')
        * $row->requireFloat('unit_price'),
    'source_key' => fn (Block $row, int|string $key): int|string => $key,
]);

Both methods return a new Block, preserve row keys, and leave the source unchanged. Array and Block rows are supported; scalar rows throw an UnexpectedValueException because a column cannot be added to them.

Existing fields are replaced by default. Disable replacement when an existing field probably indicates a programming or schema error:

$result = $orders->withColumn(
    'total',
    fn (Block $row): float => calculateTotal($row),
    overwrite: false,
);

Nested targets such as totals.amount use the same dot notation as set(). Every callback receives an independent snapshot of the original row, so one computed column cannot accidentally affect another and results do not depend on the order of the callback array.

Indexing rows with keyBy()

Use keyBy() when rows have a unique identifier and later operations need fast, readable lookup by that identifier. It replaces the table's current keys with values from the selected field while preserving each complete row.

Given this input:

$users = Block::make([
    ['id' => 'user-1', 'name' => 'Ada'],
    ['id' => 'user-2', 'name' => 'Grace'],
]);

$usersById = $users->keyBy('id');

The result is:

[
    'user-1' => ['id' => 'user-1', 'name' => 'Ada'],
    'user-2' => ['id' => 'user-2', 'name' => 'Grace'],
]

Rows can then be accessed directly through normal nested paths:

$name = $usersById->get('user-2.name'); // Grace

Duplicate identifiers throw by default because silently discarding a row can hide an import or data-quality problem. When duplicates are expected, choose an explicit policy with the KeyCollision enum:

use HiFolks\DataType\Enums\KeyCollision;

$first = $rows->keyBy('id', KeyCollision::KEEP_FIRST);
$last = $rows->keyBy('id', KeyCollision::KEEP_LAST);

KEEP_FIRST retains the first row encountered for each key. KEEP_LAST replaces its value with the last row while retaining the key's original output position. Use groupBy() instead when every duplicate row should be retained as a group.

Nested fields such as metadata.id are supported. Missing fields throw OutOfBoundsException; null, array, and object values throw UnexpectedValueException. Scalar rows are also rejected because they cannot provide an indexing field. Duplicate exceptions include the effective key and both source row keys to help locate invalid input.

The resulting keys follow PHP array-key semantics. In particular, integer 0, string "0", and boolean false refer to the same key and therefore trigger the selected collision policy. Floating-point keys are converted to integers in the same way as native PHP array keys. keyBy() returns a new Block, preserves iteration mode, and leaves the source unchanged.

The groupBy() method

Groups the elements of the Block object by a specified field.

This method takes a field name as an argument and groups the elements of the Block object based on the values of that field. Each element is grouped into an associative array where the keys are the values of the specified field and the values are arrays of elements that share that key.

use HiFolks\DataType\Block;
$data = Block::make([
    ['type' => 'fruit', 'name' => 'apple'],
    ['type' => 'fruit', 'name' => 'banana'],
    ['type' => 'vegetable', 'name' => 'carrot'],
]);
$grouped = $data->groupBy('type');
$grouped->dumpJson();
/*
{
    "fruit": [
        {
            "type": "fruit",
            "name": "apple"
        },
        {
            "type": "fruit",
            "name": "banana"
        }
    ],
    "vegetable": [
        {
            "type": "vegetable",
            "name": "carrot"
        }
    ]
}
*/

Grouping preserves valid values, including 0, "0", false, and an empty string. PHP uses the same array key for 0, "0", and false, so those values belong to group 0. This follows PHP's normal array-key behavior and avoids silently dropping valid data.

Rows whose grouping field is missing, null, or cannot be used as an array key are skipped by default. Pass defaultGroup when those rows should be collected explicitly instead:

$grouped = $data->groupBy('type', defaultGroup: 'unknown');

Using an explicit default keeps missing data visible without confusing it with valid falsey values.

The groupByFunction() method

The groupByFunction() method groups items using custom callback logic. Like the other callback-based methods, it receives the current item followed by its key. Nested arrays are provided as Block objects by default.

$fruits = [
    ['name' => 'Apple', 'type' => 'Citrus', 'quantity' => 15],
    ['name' => 'Banana', 'type' => 'Tropical', 'quantity' => 10],
    ['name' => 'Orange', 'type' => 'Citrus', 'quantity' => 8],
    ['name' => 'Mango', 'type' => 'Tropical', 'quantity' => 5],
    ['name' => 'Lemon', 'type' => 'Citrus', 'quantity' => 12]
];
$fruitsBlock = Block::make($fruits);
$groupedByQuantityRange = $fruitsBlock->groupByFunction(
    fn(Block $fruit): string =>
        match (true) {
            $fruit->getIntStrict('quantity') < 10 => 'Low',
            $fruit->getIntStrict('quantity') < 15 => 'Medium',
            default => 'High',
        },
);
// It returns:
/*
{
    "High": [
        {
            "name": "Apple",
            "type": "Citrus",
            "quantity": 15
        }
    ],
    "Medium": [
        {
            "name": "Banana",
            "type": "Tropical",
            "quantity": 10
        },
        {
            "name": "Lemon",
            "type": "Citrus",
            "quantity": 12
        }
    ],
    "Low": [
        {
            "name": "Orange",
            "type": "Citrus",
            "quantity": 8
        },
        {
            "name": "Mango",
            "type": "Tropical",
            "quantity": 5
        }
    ]
}
*/

Aggregating numeric values

Use sum(), average(), min(), and max() to aggregate a field across all rows. Field names support the same nested paths as get().

$orders = Block::make([
    ['currency' => 'EUR', 'totals' => ['amount' => 10]],
    ['currency' => 'USD', 'totals' => ['amount' => '20.50']],
    ['currency' => 'EUR', 'totals' => ['amount' => 30]],
]);

$orders->sum('totals.amount');     // 60.5
$orders->average('totals.amount'); // 20.166666666666668
$orders->min('totals.amount');     // 10
$orders->max('totals.amount');     // 30

When no field is provided, the methods aggregate the values in the Block directly:

Block::make([10, 20, 30])->sum(); // 60

Integer values, floating-point values, and numeric strings are included. Missing fields, null, booleans, and non-numeric values are ignored. An empty selection returns 0 from sum() and null from average(), min(), and max().

Conditional counts and sums

Use countWhere() and sumWhere() when an aggregate applies only to matching rows. countWhere() returns the number of rows that match the condition. sumWhere() returns a number representing the sum of a selected column across those matching rows. They express the intent directly and avoid repeating where(...)->count() or where(...)->sum() throughout reporting code:

$orders = Block::make([
    ['status' => 'paid', 'amount' => 10],
    ['status' => 'pending', 'amount' => 50],
    ['status' => 'paid', 'amount' => '20.5'],
    ['status' => 'paid', 'amount' => null],
]);

$summary = [
    'paid_orders' => $orders->countWhere('status', 'paid'),
    'paid_amount' => $orders->sumWhere('amount', 'status', 'paid'),
];

The resulting summary is:

[
    'paid_orders' => 3,
    'paid_amount' => 30.5,
]

All three paid rows are counted. The null amount is ignored by the sum, while the numeric string "20.5" is included consistently with sum().

The first sumWhere() argument is the numeric field to total. The remaining arguments describe the condition. Both methods support nested fields and use the same shorthand and operators as where():

use HiFolks\DataType\Enums\Operator;

$largeOrders = $orders->countWhere(
    'totals.amount',
    Operator::GREATER_THAN,
    100,
);

$largeOrderTotal = $orders->sumWhere(
    'totals.amount',
    'totals.amount',
    Operator::GREATER_THAN,
    100,
);

With one condition argument, the methods use where()'s boolean shorthand:

$activeOrders = $orders->countWhere('active');
$activeAmount = $orders->sumWhere('totals.amount', 'active');

With a field and value, equality follows where() and is intentionally loose. This is convenient for CSV data, where numbers commonly arrive as strings:

$rows->countWhere('quantity', 10); // matches 10 and "10"

Use Operator::STRICT_EQUAL when the PHP type is significant:

$rows->countWhere('quantity', Operator::STRICT_EQUAL, 10); // only integer 10

sumWhere() follows the same numeric rules as sum(): integers, floats, and numeric strings are included, while missing, null, boolean, and non-numeric values are ignored. Both helpers return 0 when no rows match and leave the source Block unchanged.

Building grouped summaries

Use summarizeBy() when a report needs several metrics for every value of a grouping column. It replaces the repeated groupBy()->map()->values() ceremony while keeping each calculation as a small, familiar Block operation.

Given this input:

$orders = Block::make([
    ['currency' => 'EUR', 'status' => 'paid',    'amount' => 10],
    ['currency' => 'USD', 'status' => 'pending', 'amount' => 50],
    ['currency' => 'EUR', 'status' => 'paid',    'amount' => '20.5'],
    ['currency' => 'EUR', 'status' => 'pending', 'amount' => 30],
    ['currency' => 'USD', 'status' => 'paid',    'amount' => 25],
]);

A grouped report can calculate total rows, conditional counts, and conditional sums together:

$summary = $orders->summarizeBy(
    'currency',
    [
        'orders' => fn (Block $group): int =>
            $group->count(),

        'paid_orders' => fn (Block $group): int =>
            $group->countWhere('status', 'paid'),

        'paid_amount' => fn (Block $group): int|float =>
            $group->sumWhere('amount', 'status', 'paid'),
    ],
);

The result is a flat, zero-indexed Block suitable for JSON or CSV-style output:

[
    [
        'currency' => 'EUR',
        'orders' => 3,
        'paid_orders' => 2,
        'paid_amount' => 30.5,
    ],
    [
        'currency' => 'USD',
        'orders' => 2,
        'paid_orders' => 1,
        'paid_amount' => 25,
    ],
]

The grouping field is included as the first output column. Rename it with groupAs when the report needs a different heading:

$summary = $orders->summarizeBy(
    'customer.region',
    ['orders' => fn (Block $group): int => $group->count()],
    groupAs: 'region',
);

Pass defaultGroup to retain rows whose grouping field is missing or null:

$summary = $orders->summarizeBy(
    'currency',
    ['orders' => fn (Block $group): int => $group->count()],
    defaultGroup: 'unknown',
);

Metric callbacks receive the grouped Block and the group value, making labels and other group-aware calculations possible:

$summary = $orders->summarizeBy(
    'currency',
    [
        'label' => fn (Block $group, string $currency): string =>
            $currency . ': ' . $group->count() . ' orders',
    ],
);

Each metric receives an independent snapshot of the original group. A callback cannot change the rows observed by another metric. Groups retain their first-seen order, nested grouping fields are supported, missing groups are skipped unless defaultGroup is provided, and the source Block is unchanged. The group output column cannot have the same name as a metric.

Like groupBy(), summaries follow PHP array-key semantics for group values. The values 0, "0", and false therefore share integer group 0, while an empty string remains a separate group. Null and missing values are skipped or placed in defaultGroup:

$rows = Block::make([
    ['group' => 0,     'amount' => 1],
    ['group' => '0',   'amount' => 2],
    ['group' => false, 'amount' => 4],
    ['group' => '',    'amount' => 8],
    ['group' => null,  'amount' => 16],
    [                  'amount' => 32],
]);

$summary = $rows->summarizeBy(
    'group',
    [
        'count' => fn (Block $group): int => $group->count(),
        'amount' => fn (Block $group): int|float => $group->sum('amount'),
    ],
    defaultGroup: 'unknown',
);

The result is:

[
    ['group' => 0,         'count' => 3, 'amount' => 7],
    ['group' => '',        'count' => 1, 'amount' => 8],
    ['group' => 'unknown', 'count' => 2, 'amount' => 48],
]

Grouping and summarizing by multiple columns

Use summarizeByMany() when a group is defined by a combination of two or more fields. The output remains a flat report, so it can be filtered, sorted, or exported without navigating nested group levels.

Given this input:

$orders = Block::make([
    ['region' => 'Europe',  'status' => 'paid',    'amount' => 10],
    ['region' => 'America', 'status' => 'paid',    'amount' => 25],
    ['region' => 'Europe',  'status' => 'pending', 'amount' => 50],
    ['region' => 'Europe',  'status' => 'paid',    'amount' => '20.5'],
]);

Create one summary row for every region and status combination:

$summary = $orders->summarizeByMany(
    ['region', 'status'],
    [
        'orders' => fn (Block $group): int => $group->count(),
        'amount' => fn (Block $group): int|float => $group->sum('amount'),
    ],
);

The result preserves the first-seen order of each combination:

[
    ['region' => 'Europe',  'status' => 'paid',    'orders' => 2, 'amount' => 30.5],
    ['region' => 'America', 'status' => 'paid',    'orders' => 1, 'amount' => 25],
    ['region' => 'Europe',  'status' => 'pending', 'orders' => 1, 'amount' => 50],
]

When only counts are needed, countByMany() is a shorter equivalent:

$counts = $orders->countByMany(
    ['region', 'status'],
    countAs: 'orders',
);

Missing or null grouping values cause a row to be skipped by default. Supply a default independently for each field that should retain incomplete rows:

$counts = $orders->countByMany(
    ['customer.region', 'status'],
    defaultGroups: [
        'customer.region' => 'unknown-region',
        'status' => 'unknown-status',
    ],
);

Metric callbacks receive the grouped rows and an associative array containing all group values:

$summary = $orders->summarizeByMany(
    ['region', 'status'],
    [
        'label' => fn (Block $group, array $values): string =>
            $values['region'] . ' / ' . $values['status'],
    ],
);

Multi-column grouping uses an internal type-preserving tuple identity rather than encoded display keys. Integer 0, string "0", and boolean false therefore remain three separate combinations. Grouping fields must be a unique list containing at least two non-empty string or integer fields. Group columns cannot conflict with metric names, and default values must be scalar or null.

Use the grouped aggregation helpers when you need one result for each value of another field:

$ordersByCurrency = $orders->countBy('currency');
$totalsByCurrency = $orders->sumBy('currency', 'totals.amount');

$ordersByCurrency->toArray();
// ['EUR' => 2, 'USD' => 1]

$totalsByCurrency->toArray();
// ['EUR' => 40, 'USD' => 20.5]

The available grouped helpers are countBy(), sumBy(), averageBy(), minBy(), and maxBy(). countBy() counts every row in each group, regardless of missing or null values in other fields. The numeric helpers take a second argument specifying the field to aggregate. All field arguments support nested paths.

To count only rows matching a condition, filter the collection before calling countBy(). For example, this counts opportunities with a non-null amount for each stage:

$opportunitiesByStage = $opportunities
    ->whereNotNull('Amount')
    ->countBy('Stage');

The same pattern works with where(), whereNull(), whereBetween(), whereIn(), or callback-based filter().

Filtering and sorting associative keys

Grouped aggregations return associative Blocks whose keys are the group names. Use withoutKeys() to exclude unwanted groups and sortKeys() to produce a predictable report order without leaving the fluent API:

use HiFolks\DataType\Enums\SortDirection;

$totals = $opportunities
    ->sumBy('Amount Currency', '_amount')
    ->withoutKeys('')
    ->sortKeys(SortDirection::ASC);

Both methods return a new Block, preserve key/value associations and iteration mode, and leave the original unchanged. withoutKeys() accepts multiple keys and silently ignores missing ones. sortKeys() defaults to ascending order.

Integer keys are compared numerically and string keys lexicographically with case sensitivity. For mixed keys, integers come before strings in ascending order; descending order reverses that complete ordering. PHP automatically converts numeric-string array keys such as '10' to integers, so they cannot be distinguished from integer keys after the array is created.

They also accept defaultGroup for rows with a missing or null grouping field:

$ordersByCurrency = $orders->countBy(
    groupField: 'currency',
    defaultGroup: 'unknown',
);

$totalsByCurrency = $orders->sumBy(
    groupField: 'currency',
    valueField: 'totals.amount',
    defaultGroup: 'unknown',
);

Custom aggregations with reduce()

Use reduce() when the built-in numeric operations do not cover the required aggregation. The callback receives the accumulated value, the current item, and its key:

$totalsByCurrency = $orders->reduce(
    function (array $totals, Block $order): array {
        $currency = $order->getStringStrict('currency');
        $totals[$currency] = ($totals[$currency] ?? 0)
            + $order->getFloatStrict('totals.amount');

        return $totals;
    },
    [],
);

// ['EUR' => 40.0, 'USD' => 20.5]

The exists() method

You can use the exists() method to check if an element that meets a certain condition exists. This method is a convenient way to determine if any records match your query without needing to count them explicitly.

Here’s how you can use it:

$has = $composerContent
    ->getBlock("story.content.body")->where(
        "component",
        "banner",
    )->exists();

This will return true if a banner component exists, and false if it does not.

Navigating a collection

Navigation helpers keep limiting and endpoint access inside the fluent Block API. take(), skip(), and slice() return new blocks without modifying the original data. The first*() and last*() methods return an individual item.

Goal Method Example
Keep the first items take() $rows->take(15)
Move past earlier items skip() $rows->skip(15)
Select an exact window slice() $rows->slice(15, 10)
Require an endpoint item first() / last() $rows->first()
Read an optional endpoint firstOrNull() / lastOrNull() $rows->lastOrNull()

These methods make reporting pipelines concise and readable:

$priorities = $rows
    ->orderBy('close_date')
    ->take(15)
    ->select('name', 'stage', 'amount');

Keys and reindexing

take(), skip(), and slice() preserve keys so record identities are not changed unexpectedly. Call values() when the result must be a zero-based list:

$page = $rows->skip(15)->take(15)->values();

Limits and offsets

  • take(5) returns the first five items.
  • take(-5) returns the last five items.
  • take(0) returns an empty Block.
  • skip(5) returns everything after the first five items.
  • A negative value passed to skip() throws InvalidArgumentException because its meaning would be ambiguous.
  • slice() follows PHP's array_slice() offset and length semantics, including negative values.

First and last items

first() and last() throw UnderflowException when the block is empty. Use firstOrNull() and lastOrNull() when an empty result is expected:

$firstRequired = $rows->first();
$firstOptional = $rows->firstOrNull();

Because null is also a valid item value, the nullable methods cannot distinguish an empty block from a first or last item whose value is null. Use first() or last() when that distinction matters.

Endpoint methods use the same representation as iteration: an array item is returned as a Block by default and as a native array after iterateBlock(false). Scalar items are returned unchanged.

Looping Data

The Block class implements the Iterator interface. While looping an array via Block, by default, if the current element should be an array, a Block is returned so that you can access the Block method for handling the current array item in the loop. For example, with the previous code, if you loop through $data (which is a Block object), each element in each iteration of the loop will be an array with two elements, with the keys product and price. If in the loop you need to manage the current element via Block class, you should manually call the Block::make, for example:

$table = Block::make($dataTable);
foreach ($table as $key => $item) {
    echo $item->get("price");
}

You can apply filters and then loop into the result:

$table = Block::make($dataTable);
$data = $table
    ->select('product', 'price')
    ->where('price', Operator::GREATER_THAN, 100, false);
foreach ($data as $key => $item) {
    echo $item->get("price"); // returns an integer
}

If you want to loop through $data and obtain the current $item variable as an array you should set false as a second parameter in the static make() method:

$table = Block::make($dataTable, false);
$data = $table->select('product', 'price')->where('price', Operator::GREATER_THAN, 100, false);
foreach ($data as $key => $item) {
    print_r($item); // $item is an array
}

The iterateBlock() method

With the iterateBlock() method, you can switch from array or Block for nested lists inside the main Block object if you already instanced it as a Block object. In the example above, you have the $table Block object. You can loop across the items of the $table object. If each item in the loop is itself an array (so an array of arrays), you can retrieve it as an array or a Block, depending on your needs:

$table = Block::make($dataTable);
foreach ($table as $key => $item) {
    expect($item)->toBeInstanceOf(Block::class);
    expect($key)->toBeInt();
    expect($item->get("price"))->toBeGreaterThan(10);
}

// iterateBlock(false if you need array instad of a nested Block)
foreach ($table->iterateBlock(false) as $key => $item) {
    expect($item)->toBeArray();
    expect($key)->toBeInt();
    expect($item["price"])->toBeGreaterThan(10);
}

Choosing the right callback helper

Each helper has one clear responsibility. Keeping these operations separate makes a data pipeline easier to read and prevents callback return values from changing data accidentally.

Goal Method Example Result
Compare one field with a known operator where() where('amount', Operator::GREATER_THAN, 0) A filtered Block
Select items using custom logic filter() filter(fn(Block $row): bool => ...) A filtered Block
Split by custom logic partition() partition(fn(Block $row): bool => ...) Matching and non-matching Blocks
Transform every item map() map(fn(Block $row): array => ...) A transformed Block
Perform a side effect forEach() forEach(fn(Block $row) => logger($row)) The original Block
Combine all items reduce() reduce(fn($total, $row) => ..., 0) The accumulated value

This distinction makes fluent code communicate its intention directly: filter() selects, partition() separates, map() transforms, forEach() observes, and reduce() combines.

where() vs filter()

Both methods select items, but they express different kinds of conditions:

Use where() for one field comparison. Use filter() when the condition needs custom callback logic.

Use case Method
amount > 0 where()
amount > 0 AND status !== cancelled filter()
Custom business rule filter()
Both sides of a custom business rule partition()

Internally, where() can be understood as a convenient specialized filter, while filter() is the flexible escape hatch.

For a direct field, operator, and value comparison, where() is shorter and communicates the rule clearly:

$paidOrders = $orders->where(
    'amount',
    Operator::GREATER_THAN,
    0,
);

Use filter() when the decision involves multiple fields, nullable values, calculations, or an application-specific rule:

$actionableOrders = $orders->filter(
    fn(Block $order): bool =>
        $order->getFloatStrict('amount') > 0
        && $order->getStringStrict('status') !== 'cancelled',
);

where() is the convenient declarative option; filter() is the flexible callback-based option.

Transforming items with map()

map() transforms every item and returns the callback results in a new Block. Use it when the shape or value of each item needs to change. Keys are preserved, and the original block is unchanged, so transformations can be chained without losing the source data.

$url = "https://dummyjson.com/posts";
$posts = Block::fromJsonUrl($url) // Load the Block from the remote URL
    ->getBlock("posts") // get the `posts` as Block object
    ->where(
        field:"tags",
        operator: Operator::HAS,
        value: "love",
        preserveKeys: false,
    ) // filter the posts, selecting only the posts with tags "love"
    ->map(fn(Block $element): array => [
        "title" => strtoupper((string) $element->get("title")),
        "tags" => count($element->get("tags")),
    ]);
// The `$posts` object is an instance of the `Block` class.
// The `$posts` object contains the items that matches the `where` method.
// You can access to the elements via the nested keys
// $posts->get("0.title"); // "HOPES AND DREAMS WERE DASHED THAT DAY."
// $posts->get("0.tags"); // 3

Selecting items with filter()

Use filter() when where() is not expressive enough—for example, when a decision depends on multiple fields, nullable data, or application-specific logic. The callback must return a boolean, which prevents ambiguous truthy or falsey results. Keys are preserved so records keep their identity.

$priorities = $rows->filter(
    fn(Block $row): bool =>
        $row->getStringStrict('assigned_se') === ''
        && $row->getFloatStrict('amount') > 0,
);

Call values() when a zero-based list is needed explicitly:

$priorities = $priorities->values();

Excluding items with reject()

Use reject() when the callback describes what should be removed rather than what should be retained. It is the inverse of filter() and makes negative business rules easier to read.

Given this input:

$orders = Block::make([
    'first' => ['status' => 'active'],
    'second' => ['status' => 'cancelled'],
    'third' => ['status' => 'pending'],
]);

$openOrders = $orders->reject(
    fn (Block $row): bool => $row->get('status') === 'cancelled',
);

The result contains every row for which the callback returned false:

[
    'first' => ['status' => 'active'],
    'third' => ['status' => 'pending'],
]

The callback receives the item followed by its key, must return a boolean, and is called exactly once per item. Keys and iteration mode are preserved, the source Block remains unchanged, and an empty Block returns an empty Block. Choose filter() when the callback naturally describes the rows to keep; choose reject() when it naturally describes the rows to discard.

Observing a pipeline with tap()

Use tap() when you need to inspect an intermediate result, record a metric, or write a log entry without breaking a fluent pipeline. The callback receives the whole current Block rather than each individual item.

Given this input:

$users = Block::make([
    ['name' => 'Ada', 'active' => true],
    ['name' => 'Grace', 'active' => false],
    ['name' => 'Linus', 'active' => true],
]);

$activeCount = null;

$names = $users
    ->filter(fn (Block $user): bool => $user->get('active') === true)
    ->tap(function (Block $activeUsers) use (&$activeCount): void {
        $activeCount = $activeUsers->count();
    })
    ->map(fn (Block $user): string => $user->getStringStrict('name'));

The observed count and final result are:

$activeCount;      // 2
$names->toArray(); // [0 => 'Ada', 2 => 'Linus']

tap() calls the callback exactly once—even when the Block is empty—and returns the exact same Block instance. The callback's return value is ignored. This makes tap() different from map(), which transforms every item, and forEach(), which invokes a callback once per item. Exceptions raised by the callback are not hidden and propagate to the caller.

The callback receives the actual Block, so it can mutate it. For predictable pipelines, prefer observation-only callbacks such as logging and metrics.

Splitting items with partition()

Use partition() when both sides of the same condition are needed. It evaluates each item exactly once and returns a two-element array: the first Block contains items for which the callback returned true, and the second contains those for which it returned false.

[$assigned, $unassigned] = $opportunities->partition(
    fn (Block $row): bool =>
        $row->getStringStrict('Assigned Solution Engineer') !== '',
);

Every source item belongs to exactly one result, so no opportunity is lost between the two groups. This is particularly useful here because missing and null values become an empty string through getStringStrict() and therefore belong to $unassigned.

Both returned Blocks preserve the original keys and iteration representation; the source Block is unchanged. Use values() on either result when a zero-based list is required:

$assigned = $assigned->values();
$unassigned = $unassigned->values();

Like filter(), the callback receives the item followed by its key and must return an actual boolean. Returning values such as 1, 0, or a non-empty string throws an UnexpectedValueException, preventing ambiguous truthy and falsey classification.

Performing side effects with forEach()

forEach() visits every item without replacing the block's values. It returns the original Block, so it is intended for logging, output, notifications, or other side effects. Its callback result is intentionally ignored; this prevents a logging callback from accidentally turning the data into null values. Use map() when callback results should become the new values.

$rows->forEach(
    fn(Block $row, int|string $key) => logger()->info(
        'Processing row',
        ['key' => $key, 'row' => $row->toArray()],
    ),
);

Callback arguments and item representation

map(), filter(), partition(), forEach(), reduce(), and groupByFunction() consistently pass the item first and its key second. Callbacks may omit the key when it is not needed. This shared contract makes callbacks reusable and removes method-specific argument surprises.

Nested arrays are Block objects by default. After calling iterateBlock(false), the same callback methods receive native arrays instead. Scalar items remain scalar in either mode.

Validating Data

You can validate the data in the Block object with JSON schema. JSON Schema is a vocabulary used to annotate and validate JSON documents.

More info about JSON Schema: https://json-schema.org/learn/getting-started-step-by-step

If you need some common/popular schemas, you can find some schemas here: https://www.schemastore.org/json/ For example:

Or you can build your own schema according to the JSON schema specifications: https://json-schema.org/learn/getting-started-step-by-step#create-a-schema-definition

$file = "./.github/workflows/run-tests.yml";
$workflow = Block::fromYamlFile($file);
$workflow->validateJsonViaUrl(
    'https://json.schemastore.org/github-workflow'
    ); // TRUE if the Block is a valid GitHub Actions Workflow

Or you can define your own schema:

$schemaJson = <<<'JSON'
{
    "type": "array",
    "items" : {
        "type": "object",
        "properties": {
            "name": {
                "type": "string"
            },
            "fruit": {
                "type": "string"
            },
            "wikipedia": {
                "type": "string"
            },
            "color": {
                "type": "string"
            },
            "rating": {
                "type": "number"
            }
        }
    }
}
JSON;

And then validate it with your Block object:

$fruitsArray = [
    [
        'name' => 'Avocado',
        'fruit' => '🥑',
        'wikipedia' => 'https://en.wikipedia.org/wiki/Avocado',
        'color' => 'green',
        'rating' => 8,
    ],
    [
        'name' => 'Apple',
        'fruit' => '🍎',
        'wikipedia' => 'https://en.wikipedia.org/wiki/Apple',
        'color' => 'red',
        'rating' => 7,
    ],
    [
        'name' => 'Banana',
        'fruit' => '🍌',
        'wikipedia' => 'https://en.wikipedia.org/wiki/Banana',
        'color' => 'yellow',
        'rating' => 8.5,
    ],
    [
        'name' => 'Cherry',
        'fruit' => '🍒',
        'wikipedia' => 'https://en.wikipedia.org/wiki/Cherry',
        'color' => 'red',
        'rating' => 9,
    ],
];

$data = Block::make($fruitsArray);
$data->validateJsonWithSchema($schemaJson);
// true if the Block is valid.

If you are starting to use the Data Block and testing it just to gain confidence, implementing different scenarios, or testing a non-valid JSON, try changing the "rating" type from number to integer (the validation should fail because in the JSON, we have ratings with decimals). And, yes, to change on the fly the schema you can use the Block object :)

// load the schema as Block object...
$schemaBlock = Block::fromJsonString($schemaJson);
// so that you can change the type
$schemaBlock->set(
    "items.properties.rating.type",
    "integer"
);
// the validation should be false because integer vs number
$data->validateJsonWithSchema(
    $schemaBlock->toJson()
);

Applying functions

The applyField() method applies a callable function to the value of a specified field and sets the result to another field. This method supports method chaining.

Parameters

  • key (string|int): The key of the field whose value will be processed.
  • targetKey (string|int): The key where the result of the callable function should be stored.
  • callable (callable): The function to apply to the field value. This function should accept a single argument (the value of the field) and return the processed value.

Returns

  • self: Returns the instance of the class for method chaining.

Example Usage

<?php

// Assuming $object is an instance of the class that contains the applyField method
$object
    ->set('name', 'John Doe')
    ->applyField('name', 'uppercase_name', function($value) {
        return strtoupper($value);
    });

echo $object->get('uppercase_name'); // Outputs: JOHN DOE

Testing

composer test

Upgrading from 1.x to 2.0

Version 2.0 makes callback behavior consistent and separates transformation from side effects:

  • Replace transformation-style forEach() calls with map(). In 2.0, forEach() ignores callback return values and returns the original block.
  • map(), filter(), partition(), forEach(), reduce(), and groupByFunction() receive the current item and then its key.
  • groupByFunction() now follows the configured iteration representation: nested arrays are Block objects by default and native arrays after iterateBlock(false).
  • Rename the where() named argument preseveKeys to preserveKeys.
  • values() now explicitly returns a zero-indexed block.

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security Vulnerabilities

Please review our security policy on reporting security vulnerabilities.

License

The MIT License (MIT). Please see License File for more information.

Thanks to

Thank you to everyone who has provided feedback, opened issues, or created pull requests. A special thanks to all the contributors! You can view the full list of contributors in this section.

The PHP ecosystem offers many tools that help developers enhance productivity, reliability, and efficiency. One such tool is JetBrains PhpStorm. JetBrains supports the open-source community by offering licenses for various open-source projects. More information can be found in the Open Source section of the JetBrains website.

PhpStorm logo

I’m thrilled to share that JetBrains has provided an Open Source license for the PHP Data Block project. This recognition of PHP Data Block as a valuable open-source software fills me with joy.

Thank you!

Roberto

About

PHP Package for handling, querying, filtering, and setting nested data structures

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

145 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages