How to Stream JSON Responses in Laravel 9 and 10

Tom Ellis
Dev Genius
Published in
6 min readJan 17, 2024

--

Updated 13th February 2024 — Since writing this article, Streamed JSON Response support has been added into Laravel 10.43 by Peter Elmered.

You can see the PR here and read about it on Laravel News.

When working with JSON API’s that are paginated, sometimes its useful to get one large response back, without having to loop through and make a request for each page.

The problem with this is, that you have to put the data you want to send in the response into memory, and that can cause different memory issues with PHP depending on your setup.

Enter Streamed JSON Responses.

What are Streamed JSON Responses?

In Symfony 6.3 (released May 2023) a StreamedJsonResponse class was added for efficient JSON streaming.

Below are some links for further reading if you are interested:

  1. Original work on getting JSON Streaming working: https://github.com/alexander-schranz/efficient-json-streaming-with-symfony-doctrine?tab=readme-ov-file
  2. The Pull Request: https://github.com/symfony/symfony/pull/47709
  3. Symfony Docs for 6.3: https://symfony.com/doc/6.3/components/http_foundation.html#streaming-a-json-response

JSON is not a simple data structure, say in comparision to csv style data, meaning you can’t easily stream data in the same way.

The StreamedJsonResponse class makes this possible by breaking your JSON structure up to parts and sensibly working out how to send each bit of data.

Each bit of data is then flush to the response stream, taking up less memory than it normally would.

The idea when using a StreamedJsonResponse class is to combine it with a PHP Generator, so its memory efficient.

Below are some metrics taken from one of the above links to how the difference in memory used. The metrics are for 100000 Articles (nginx + php-fpm 7.4 — Macbook Pro 2013):

As you can see, these are some impressive stats.

What Does This Look Like?

Taking the Symfony docs as an example, your controller action would look something like this in Laravel:

<?php

namespace App\Http\Controllers;

use Generator;
use App\Models\Article;
use Symfony\Component\HttpFoundation\StreamedJsonResponse;

class ArticlesController extends Controller
{
public function list()
{
return new StreamedJsonResponse()[
'articles' => $this->getArticles(),
]);
}

protected function getArticles(): Generator
{
foreach (Article::query()->cursor() as $article) {
yield $article;
}
}
}

Edit — 2024–02–29

Since cursor returns a LazyCollection which utilises Generators we can actually simplify the above to:

<?php

namespace App\Http\Controllers;

use Generator;
use App\Models\Article;
use Symfony\Component\HttpFoundation\StreamedJsonResponse;

class ArticlesController extends Controller
{
public function list()
{
return new StreamedJsonResponse()[
'articles' => Article::query()->cursor(),
]);
}
}

Note: Laravel 10 should have the correct version of Symfony to include the StreamedJsonResponse class.

Laravel 9 will only have this class as long as you have ran composer update recently.

Here we are also taking advantage of the LazyCollection returned because of the ->cursor() call we are doing.

Making It More Laravel Like

Now the above example shows how to use this in Laravel, but it doesn’t feel very Laravel like.

Lets Use Macros!

We can take advantage of Laravels Macroable support (say that 3 times fast) by defining a macro ResponseFactory class.

By adding the following to a service provider:

ResponseFactory::macro('streamJson', function ($data, $status = 200, $headers = [], $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS) {
return new StreamedJsonResponse(
$data,
$status,
$headers,
$encodingOptions
);
});

So it looks something like this:

<?php

namespace App\Providers;


use Illuminate\Http\JsonResponse;
use Illuminate\Routing\ResponseFactory;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\HttpFoundation\StreamedJsonResponse;

class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{

}

/**
* Bootstrap any application services.
*/
public function boot(): void
{
ResponseFactory::macro('streamJson', function ($data, $status = 200, $headers = [], $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS) {
return new StreamedJsonResponse(
$data,
$status,
$headers,
$encodingOptions
);
});
}
}

We can now do the following in our controller:

<?php

namespace App\Http\Controllers;

use Generator;
use App\Models\Article;

class ArticlesController extends Controller
{
public function list()
{
return response()->streamJson([
'articles' => $this->getArticles(),
]);
}

protected function getArticles(): Generator
{
foreach (Articles::query()->cursor() as $article) {
yield $article;
}
}
}

What About Testing?

Well I’m glad you asked!

Our app in its current state can’t be tested to assert the JSON contents, because we haven’t streamed a response. Trying to assert the JSON response would fail, as we technically have no response:

public function test_the_application_returns_a_successful_stream_response(): void
{
$response = $this->get('/articles');

$response
->assertOk()
->assertJsonCount(3, 'articles')
;
}

Running the following would result in a failure:

laravel-testing $ php artisan test

FAILED Tests\Feature\ExampleTest > the application returns a successful stream response ErrorException
Undefined property: Symfony\Component\HttpFoundation\StreamedJsonResponse::$exception

at tests/Feature/ExampleTest.php:31
27▕
28▕ $response
29▕ ->assertOk()
➜ 30▕ ->assertJsonCount(3, 'articles')
31▕ ;
32▕ }
33▕ }

Our first problem is that Laravel expects us to return an instance of Illuminate\Http\Response (which has a $exception property) from our controllers, which isn’t always going to be the case, particulary if you use response()->stream or response()->download . And even if it did, it would still fail as a StreamedJsonResponse class doesn’t have any content to assert, as the response is sent a different way, i.e streamed.

Lets Use Macros…again!

Again, we can take advantage of Laravels Macroable support by defining a macro for the TestResponse class.

If you don’t know what the TestResponse class is, when doing the following in a unit test, the call returns an instance of this class, and this is what you would do your assertions against.

$response = $this->get('/');

The TestResponse is given the actual response returned from the controller call, that the assertions are then ran against.

If we define the following macro we can convert the response to a JsonResponse, so we can assert the JSON structure how we normally would.

$getResponseContent = function(Response $response) {
ob_start();
$response->send();

return ob_get_clean();
};

TestResponse::macro('convertStreamedJsonResponseToJsonResponse', function()
use($getResponseContent) {

/** @var TestResponse $this */
$this->baseResponse = new JsonResponse(
$getResponseContent($this->baseResponse),
$this->baseResponse->getStatusCode(),
$this->baseResponse->headers->all(),
0,
true
);

return $this;
});

Now lets breakdown what the code is doing. We’ll cover the simpliest part first:

$this->baseResponse = new JsonResponse(
$getResponseContent($this->baseResponse),
$this->baseResponse->getStatusCode(),
$this->baseResponse->headers->all(),
0,
true
);

This code replaces the response on the TestResponse that are assertions are ran against. It gets the content generated from the StreamedJsonResponse and generates a JsonResponse from this.

The following code is used to get the generated response from the StreamedJsonResponse using the output buffer. You can read more about Output buffering here if you want more information.

$getResponseContent = function(Response $response) {
ob_start();
$response->send();

return ob_get_clean();
};

Our updated service provider now looks like this:

<?php

namespace App\Providers;

use Illuminate\Http\JsonResponse;
use Illuminate\Routing\ResponseFactory;
use Illuminate\Support\ServiceProvider;
use Illuminate\Testing\TestResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedJsonResponse;

class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{

}

/**
* Bootstrap any application services.
*/
public function boot(): void
{
ResponseFactory::macro('streamJson', function ($data, $status = 200, $headers = [], $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS) {
return new StreamedJsonResponse(
$data,
$status,
$headers,
$encodingOptions
);
});

$getResponseContent = function(Response $response) {
ob_start();
$response->send();

return ob_get_clean();
};

TestResponse::macro('convertStreamedJsonResponseToJsonResponse', function() use($getResponseContent) {

/** @var TestResponse $this */
$this->baseResponse = new JsonResponse(
$getResponseContent($this->baseResponse),
$this->baseResponse->getStatusCode(),
$this->baseResponse->headers->all(),
0,
true
);

return $this;
});
}
}

We can now update our unit test to use this new Macro.

public function test_the_application_returns_a_successful_stream_response(): void
{
$response = $this->get('/articles');

$response
->convertStreamedJsonResponseToJsonResponse()
->assertOk()
->assertJsonCount(3, 'articles')
;
}

Now when we run the unit tests they pass:

laravel-testing $ php artisan test

PASS Tests\Unit\ExampleTest
✓ that true is true

PASS Tests\Feature\ExampleTest
✓ the application returns a successful response 0.02s
✓ the application returns a successful stream response 0.01s

Tests: 2 passed (3 assertions)
Duration: 0.14s

Summary

In this article we covered how to stream large JSON responses from our API controllers, as well as being able to unit test them, without using the Laravel way of doing things.

Hopefully this will help you if you need to do this in your application now, or in the future.

--

--

PHP and JavaScript hacker. Symfony and Laravel tinkerer. Open source developer.