data types and standard library
JSON Lines and Newline-Delimited Imports
JSON Lines, often called NDJSON, stores one complete JSON value per line. It is common in exports, logs, queues, bulk imports, analytics events, and data handoffs between systems.
The key advantage is streaming. PHP can read one line, decode one record, validate it, process it, and move on. It does not need to load a huge JSON array before work can begin.
Decode one record per line
Each line must be valid JSON by itself.
<?php
declare(strict_types=1);
$lines = [
'{"id":1,"email":"sam@example.com"}',
'{"id":2,"email":"lee@example.com"}',
];
foreach ($lines as $line) {
$record = json_decode($line, true, flags: JSON_THROW_ON_ERROR);
echo $record['id'] . ': ' . $record['email'] . PHP_EOL;
}
// Prints:
// 1: sam@example.com
// 2: lee@example.com
This is different from a normal JSON file containing one large array. There is no opening [ at the top and no commas between lines.
Keep line numbers in errors
Import errors should identify the line that failed. Without a line number, a large import file is painful to debug.
<?php
declare(strict_types=1);
function decodeJsonLine(string $line, int $lineNumber): array
{
try {
$record = json_decode($line, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new InvalidArgumentException('Line ' . $lineNumber . ' is not valid JSON.', 0, $exception);
}
if (!is_array($record)) {
throw new InvalidArgumentException('Line ' . $lineNumber . ' must contain a JSON object.');
}
return $record;
}
try {
decodeJsonLine('{"id":', 3);
} catch (InvalidArgumentException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// Line 3 is not valid JSON.
This pattern belongs near the import boundary, before records reach application services or database code.
Validate every record
Valid JSON is not enough. Each record still needs the fields your importer expects.
<?php
declare(strict_types=1);
function importCustomerRecord(array $record, int $lineNumber): string
{
if (!isset($record['email']) || !is_string($record['email']) || trim($record['email']) === '') {
throw new InvalidArgumentException('Line ' . $lineNumber . ' is missing a customer email.');
}
return strtolower($record['email']);
}
$record = ['id' => 1, 'email' => 'SAM@EXAMPLE.COM'];
echo importCustomerRecord($record, 1) . PHP_EOL;
// Prints:
// sam@example.com
Line-level validation lets an import report precise failures instead of stopping later with a vague database or type error.
Stream from a string or file
In production, you normally read from a file handle. For a small example, a generator over a string shows the same control flow.
<?php
declare(strict_types=1);
function linesFromText(string $text): iterable
{
foreach (explode("\n", $text) as $lineNumber => $line) {
$line = trim($line);
if ($line === '') {
continue;
}
yield $lineNumber + 1 => $line;
}
}
$text = "{\"sku\":\"KB-101\"}\n\n{\"sku\":\"MS-202\"}";
foreach (linesFromText($text) as $lineNumber => $line) {
$record = json_decode($line, true, flags: JSON_THROW_ON_ERROR);
echo $lineNumber . ': ' . $record['sku'] . PHP_EOL;
}
// Prints:
// 1: KB-101
// 3: MS-202
Notice that blank lines are skipped but the original line numbers are preserved. That makes error messages match the file the user uploaded.
Write JSON Lines
When writing JSON Lines, encode each record compactly and append a newline.
<?php
declare(strict_types=1);
$records = [
['event' => 'order_created', 'id' => 101],
['event' => 'order_paid', 'id' => 101],
];
foreach ($records as $record) {
echo json_encode($record, JSON_THROW_ON_ERROR) . PHP_EOL;
}
// Prints:
// {"event":"order_created","id":101}
// {"event":"order_paid","id":101}
Do not pretty-print JSON Lines. Pretty JSON spreads one record over multiple lines, which breaks the format.
Decide whether to stop or collect errors
Some imports should stop on the first bad line. Others should collect errors and continue so the user can fix a batch of problems at once.
<?php
declare(strict_types=1);
$lines = [
1 => '{"email":"sam@example.com"}',
2 => '{"email":""}',
3 => '{"email":"lee@example.com"}',
];
$emails = [];
$errors = [];
foreach ($lines as $lineNumber => $line) {
try {
$record = json_decode($line, true, flags: JSON_THROW_ON_ERROR);
if (!isset($record['email']) || trim((string) $record['email']) === '') {
throw new InvalidArgumentException('Line ' . $lineNumber . ' is missing an email.');
}
$emails[] = $record['email'];
} catch (Throwable $exception) {
$errors[] = $exception->getMessage();
}
}
echo count($emails) . ' valid records' . PHP_EOL;
echo implode('; ', $errors) . PHP_EOL;
// Prints:
// 2 valid records
// Line 2 is missing an email.
Make this decision deliberately. Silent partial imports are hard to support.
What to remember
JSON Lines is for record-by-record processing. Decode one line at a time, preserve line numbers, validate each record, avoid pretty printing, and choose a clear error strategy before writing to the database.
Practice
Task: Import customer emails from JSON Lines
Write a small importer for newline-delimited customer records.
Requirements
- Use
declare(strict_types=1);. - Accept a string containing JSON Lines.
- Skip blank lines.
- Preserve the original line number in messages.
- Decode each non-blank line with
JSON_THROW_ON_ERROR. - Require each record to contain a non-empty
emailstring. - Collect valid emails into an array.
- Collect line-level errors into an array.
- Print the valid emails and errors.
- Include the expected output as comments in the same PHP code block.
The importer should show the difference between a good record, a bad record, and a blank line.
Show solution
<?php
declare(strict_types=1);
function importEmails(string $jsonLines): array
{
$emails = [];
$errors = [];
foreach (explode("\n", $jsonLines) as $index => $line) {
$lineNumber = $index + 1;
$line = trim($line);
if ($line === '') {
continue;
}
try {
$record = json_decode($line, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($record)) {
throw new InvalidArgumentException('Line ' . $lineNumber . ' must contain a JSON object.');
}
if (!isset($record['email']) || !is_string($record['email']) || trim($record['email']) === '') {
throw new InvalidArgumentException('Line ' . $lineNumber . ' is missing an email.');
}
$emails[] = strtolower($record['email']);
} catch (Throwable $exception) {
$errors[] = $exception->getMessage();
}
}
return [
'emails' => $emails,
'errors' => $errors,
];
}
$input = "{\"email\":\"SAM@example.com\"}\n\n{\"email\":\"\"}\n{\"email\":\"lee@example.com\"}";
$result = importEmails($input);
echo 'Emails: ' . implode(', ', $result['emails']) . PHP_EOL;
echo 'Errors: ' . implode('; ', $result['errors']) . PHP_EOL;
// Prints:
// Emails: sam@example.com, lee@example.com
// Errors: Line 3 is missing an email.
The importer handles records one line at a time, skips blank lines, preserves useful line numbers, and keeps valid results separate from errors. That is the core shape of a maintainable JSON Lines import.