Whether you've ever had to deal with local files, HTTP requests or compressed files, you dealt with streams but... did you really get to know them?
I think that this is one of the most misunderstood concepts in PHP, and, as a consequence I've seen quite a few bugs being introduced because the lack of some fundamental knowledge.
In this article I'll try to explain what streams really are and how to work with them. We will see many functions used to work with streams as well as lots of examples, but it is not my intention to "redocument" all of them in any way.
Before learning what streams are, we first need to approach resources.
Resources
Resources are simply references or pointers to external resources, such as a file, a database, network or SSH connection, for example.
There are several types of resources, such as curl
- created by curl_init()
, process
, created by proc_open
and stream
, created by functions like fopen()
, opendir
among others.
Streams
Streams are the way that PHP generalises types of resources that have common behaviour, that is, resources can be read from and written to linearly, like a cassette tape (damn, I am getting old). Some examples of streams are file resources, HTTP response bodies and compressed files, just to name a few.
Streams are incredibly useful as they enable us to work with resources that range from a few bytes to several GBs in size and, an attempt of read them entirely, for example, would exhaust our available memory.
Creating a stream with fopen
fopen(
string $filename,
string $mode,
bool $use_include_path = false,
?resource $context = null
): resource|false
fopen
opens a file or network resource[1], depending on the path provided to its first parameter. As said before, this resource is of type stream:
$fileStream = fopen('/tmp/test', 'w');
echo get_resource_type($fileStream); // 'stream'
If $filename
is provided in the form scheme://
, it is assumed to be a URL and PHP will try to find a supported protocol handlers/wrappers that matches the path, such as file://
- to handle local files, http://
- to work on remote HTTP/S resources, ssh2://
- to handle SSH connections or php://
- that allows us access PHP's own input and output streams, such as php://stdin
, php://stdout
and php://stderr
.
$mode
defines the type of access you require to the stream, that is, whether you need only read access, only write, read and write, read/write from the beginning of the stream or end, and so on.
The mode also depends on the type of resource you are working on. For example:
$fileStream = fopen('/tmp/test', 'w');
$networkStream = fopen('https://google.com', 'r');
Opening a writable stream using the wrapper https://
, for example, does not work:
fopen('https://google.com', 'w'); // Failed to open stream: HTTP wrapper does not support writeable connections
[1] Using fopen
with network or remote resources only works when allow_url_fopen
is enabled on php.ini
. For more information, check the documentation.
So, now we've got a stream resource, what can we do with them?
Writing into a file stream with fwrite
fwrite(resource $stream, string $data, ?int $length = null): int|false
fwrite
enables us to write the contents provided to $data
into a stream. If $length
is supplied, it writes only the given supplied number of bytes. Let's see an example:
$fileStream = fopen('/tmp/test', 'w');
fwrite($fileStream, "The quick brown fox jumps over the lazy dog", 10);
In this example, as we provided $length = 10
, so only part of the content was written - "The quick " - ignoring the rest.
Notice that we opened the file stream with $mode = 'w'
, which enabled us to write content into the file. If, instead, we had opened the file with $mode = 'r'
, we would get a message such as fwrite(): Write of 8192 bytes failed with errno=9 Bad file descriptor
.
Let's see another example, now writing the whole content into the file stream:
$fileStream = fopen('/tmp/test', 'w');
fwrite($fileStream, "The quick brown fox jumps over the lazy dog");
Now, since we haven't provided $length
, the whole content was written into the file.
Writing into a stream moves the position of the read/write pointer to end end of the sequence. In this case, the string written into the stream has 44 characters, therefore, the pointer's position now should be 43.
Besides writing into a file, fwrite
can write in other types of streams, such as sockets. Example extracted from the docs:
$sock = fsockopen("ssl://secure.example.com", 443, $errno, $errstr, 30);
if (!$sock) die("$errstr ($errno)\n");
$data = "foo=" . urlencode("Value for Foo") . "&bar=" . urlencode("Value for Bar");
fwrite($sock, "POST /form_action.php HTTP/1.0\r\n");
fwrite($sock, "Host: secure.example.com\r\n");
fwrite($sock, "Content-type: application/x-www-form-urlencoded\r\n");
fwrite($sock, "Content-length: " . strlen($data) . "\r\n");
fwrite($sock, "Accept: */*\r\n");
fwrite($sock, "\r\n");
fwrite($sock, $data);
$headers = "";
while ($str = trim(fgets($sock, 4096)))
$headers .= "$str\n";
echo "\n";
$body = "";
while (!feof($sock))
$body .= fgets($sock, 4096);
fclose($sock);
Reading streams with fread
fread(resource $stream, int $length): string|false
With fread
you can read up to $length
bytes from a stream, starting from the current read pointer. It is binary-safe and it works with local and network resources, as we will see in the examples.
Calling fread
consecutively will read a chunk and then move the read pointer to the end of this chunk. Example, considering the file written in the previous example:
# Content: "The quick brown fox jumps over the lazy dog"
$fileStream = fopen('/tmp/test', 'r');
echo fread($fileStream, 10) . PHP_EOL; // 'The quick '
echo ftell($fileStream); // 10
echo fread($fileStream, 10) . PHP_EOL; // 'brown fox '
echo ftell($fileStream); // 20
We will come back to ftell
soon, but what it does is simply return the current position of the read pointer.
The reading stops (returning false
), as soon as one of the following occurs (copied from the docs, you'll understand later):
length
bytes have been read- EOF (end of file) is reached
- a packet becomes available or the socket timeout occurs (for network streams)
- if the stream is read buffered and it does not represent a plain file, at most one read of up to a number of bytes equal to the chunk size (usually 8192) is made; depending on the previously buffered data, the size of the returned data may be larger than the chunk size.
I don't know if you had the same felling, but this last part is pretty cryptic, so let's break it down.
"if the stream is read buffered"
Stream reads and writes can be buffered, that is, the content may be stored internally. It is possible to disable/enable the buffering, as well as set their sizes using stream_set_read_buffer and stream-set-write-buffer, but according to this comment on the PHP doc's Github, the description of these functions can be misleading.
This is where things get interesting, as this part of the documentation is really obscure. As per the comment, setting stream_set_read_buffer($stream, 0)
would disable the read buffering, whereas stream_set_read_buffer($stream, 1)
or stream_set_read_buffer($stream, 42)
would simply enable it, ignoring its size (depending on the stream wrapper, which can override this default behaviour).
"... at most one read of up to a number of bytes equal to the chunk size (usually 8192) is made"
The chunk size is usually 8192 bytes or 8 KiB, as we will confirm in a bit. We can change this value using stream_set_chunk_size
. Let's see it in action:
$f = fopen('https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.2-x86_64.iso', 'rb');
$previousPos = 0;
$chunkSize = 1024;
$i = 1;
while ($chunk = fread($f, $chunkSize)) {
$bytesRead = (ftell($f) - $previousPos);
$previousPos = ftell($f);
echo "Iteration: {$i}. Bytes read: {$bytesRead}" . PHP_EOL;
$i++;
}
Output:
Iteration: 1. Bytes read: 1024
Iteration: 2. Bytes read: 1024
Iteration: 3. Bytes read: 1024
...
Iteration: 214016. Bytes read: 1024
Iteration: 214017. Bytes read: 169
What happened in this case was clear:
- We wanted up to 1024 bytes in each
fread
call and that's what we got - In the last call there were only 169 bytes remainder, which were returned
- When there was nothing else to return, that is, EOF was reached
fread
returnedfalse
and the loop finished.
Now let's increase considerably the length provided to fread
to 1 MiB and see what happens:
$f = fopen('https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.2-x86_64.iso', 'rb');
$previousPos = 0;
$chunkSize = 1048576; // 1 MiB
$i = 1;
while ($chunk = fread($f, $chunkSize)) {
$bytesRead = (ftell($f) - $previousPos);
$previousPos = ftell($f);
echo "Iteration: {$i}. Bytes read: {$bytesRead}" . PHP_EOL;
$i++;
}
Output:
Iteration: 1. Bytes read: 1378
Iteration: 2. Bytes read: 1378
Iteration: 3. Bytes read: 1378
...
Iteration: 24. Bytes read: 1074
Iteration: 25. Bytes read: 8192
Iteration: 26. Bytes read: 8192
...
Iteration: 26777. Bytes read: 8192
Iteration: 26778. Bytes read: 8192
Iteration: 26779. Bytes read: 293
So, even though we tried to read 1 MiB using fread
, it read up to 8192 bytes - same value that the docs said it would. Interesting. Let's see another experiment:
$f = fopen('https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.2-x86_64.iso', 'rb');
$previousPos = 0;
$chunkSize = 1048576; // 1 MiB
$i = 1;
stream_set_chunk_size($f, $chunkSize); // Just added this line
while ($chunk = fread($f, $chunkSize)) {
$bytesRead = (ftell($f) - $previousPos);
$previousPos = ftell($f);
echo "Iteration: {$i}. Bytes read: {$bytesRead}" . PHP_EOL;
$i++;
}
And the output:
Iteration: 1. Bytes read: 1378
Iteration: 2. Bytes read: 1378
Iteration: 3. Bytes read: 1378
...
Iteration: 12. Bytes read: 533
Iteration: 13. Bytes read: 16384
Iteration: 14. Bytes read: 16384
...
Iteration: 13386. Bytes read: 16384
Iteration: 13387. Bytes read: 16384
Iteration: 13388. Bytes read: 13626
Notice that now fread
read up to 16 KiB - not even close to what we wanted, but we've seen that stream_set_chunk_size
did work, but there are some hard limits, that I suppose that depends also on the wrapper. Let's put that in practice with another experiment, using a local file this time:
$f = fopen('alpine-standard-3.20.2-x86_64.iso', 'rb');
$previousPos = 0;
$chunkSize = 1048576; // 1 MiB
$i = 1;
while ($chunk = fread($f, $chunkSize)) {
$bytesRead = (ftell($f) - $previousPos);
$previousPos = ftell($f);
echo "Iteration: {$i}. Bytes read: {$bytesRead}" . PHP_EOL;
$i++;
}
Output:
Iteration: 1. Bytes read: 1048576
Iteration: 2. Bytes read: 1048576
...
Iteration: 208. Bytes read: 1048576
Iteration: 209. Bytes read: 1048576
Aha! So using the local file handler we were able to fread
1 MiB as we wanted, and we did not even need to increase the buffer/chunk size with stream_set_chunk_size
.
Wrapping up
I think that now the description is less cryptic, at least. Let's read it again (with some interventions):
if the stream is read buffered ...
and it does not represent a plain file (that is, local, not a network resource), ...
at most one read of up to a number of bytes equal to the chunk size (usually 8192) is made (and in our experiments we could confirm that this is true, at least one read of the chunk size was made); ...
depending on the previously buffered data, the size of the returned data may be larger than the chunk size (we did not experience that, but I assume it may happen depending on the wrapper).
There is definitely some room to play here, but I will challenge you. What would happen if you disable the buffers while reading a file? And a network resource? What if you write into a file?
ftell
ftell(resource $stream): int|false
ftell
returns the position of the read/write pointer (or null
when the resource is not valid).
# Content: "The quick brown fox jumps over the lazy dog"
$fileStream = fopen('/tmp/test', 'r');
fread($fileStream, 10); # "The quick "
echo ftell($fileStream); 10
stream_get_meta_data
stream_get_meta_data(resource $stream): array
stream_get_meta_data
returns information about the stream in form of an array. Let's see an example:
# Content: "The quick brown fox jumps over the lazy dog"
$fileStream = fopen('/tmp/test', 'r');
var_dump(stream_get_meta_data($fileStream)):
The previous example would return in something like this:
array(9) {
["timed_out"]=>
bool(false)
["blocked"]=>
bool(true)
["eof"]=>
bool(false)
["wrapper_type"]=>
string(9) "plainfile"
["stream_type"]=>
string(5) "STDIO"
["mode"]=>
string(1) "r"
["unread_bytes"]=>
int(0)
["seekable"]=>
bool(true)
["uri"]=>
string(16) "file:///tmp/test"
}
This function's documentation is pretty honest describing each value ;)
fseek
fseek(resource $stream, int $offset, int $whence = SEEK_SET): int
fseek
sets the read/write pointer on the opened stream to the value provided to $offset
.
The position will be updated based on $whence
:
-
SEEK_SET
: Position is set to$offset
, that is, if you call -
SEEK_CUR
: Position is set based on the current one, that is, current +$offset
-
SEEK_END
: Position is set to End Of File +$offset
.
Using SEEK_END
we can provide a negative value to $offset
and go backwards from EOF. Its return value can be used to assess if the position has been set successfully (0) or has failed (-1).
Let's see some examples:
# Content: "The quick brown fox jumps over the lazy dog\n"
$fileStream = fopen('/tmp/test', 'r+');
fseek($fileStream, 4, SEEK_SET);
echo fread($fileStream, 5); // 'quick'
echo ftell($fileStream); // 9
fseek($fileStream, 7, SEEK_CUR);
echo ftell($fileStream); // 16, that is, 9 + 7
echo fread($fileStream, 3); // 'fox'
fseek($fileStream, 5, SEEK_END); // Sets the position past the End Of File
echo ftell($fileStream); // 49, that is, EOF (at 44th position) + 5
echo fread($fileStream, 3); // ''
echo ftell($fileStream); // 49, nothing to read, so read/write pointer hasn't changed
fwrite($fileStream, 'foo');
ftell($fileStream); // 52, that is, previous position + 3
fseek($fileStream, -3, SEEK_END);
ftell($fileStream); // 49, that is, 52 - 3
echo fread($fileStream, 3); // 'foo'
Some important considerations
As we've seen in this example, it is possible we seek past the End Of File and even read in an unwritten area (which returns 0 bytes), but some types of streams do not support it.
An important consideration is that not all streams can be seeked, for instance, you cannot
fseek
a remote resource:
$f = fopen('https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.2-x86_64.iso', 'rb');
fseek($f, 10); WARNING fseek(): Stream does not support seeking
This obviously makes total sense, as we cannot "fast-forward" and set a position on a remote resource. The stream in this case is only read sequentially, like a cassette tape.
We can determine if the stream is seekable or not via the seekable
value returned by stream_get_meta_data
that we've seen before.
- We can
fseek
a resource opened in append mode (a
ora+
), but the data will always be appended.
rewind
rewind(resource $stream): bool
This is a pure analogy of rewinding a videotape before returning it to video store. As expected, rewind
sets the position of the read/write pointer to 0, which is basically the same as calling fseek
with $offset
0.
The same considerations we've seen for fseek
applies for rewind
, that is:
- You cannot
rewind
an unseekable stream -
rewind
on a resource opened in append mode will still write from the current position - the write pointer is not updated.
How about file_get_contents
?
So far we've been working directly with resources. file_get_contents
is a bit different, as it accepts the file path and returns the whole file content as a string, that is, it implicitly opens the resource.
file_get_contents(
string $filename,
bool $use_include_path = false,
?resource $context = null,
int $offset = 0,
?int $length = null
): string|false
Similar to fread
, file_get_contents
can work on local and remote resources, depending on the $filename
we provide:
# Content: "The quick brown fox jumps over the lazy dog"
echo file_get_contents('/tmp/test'); // "The quick brown fox jumps over the lazy dog\n"
echo file_get_contents('https://www.php.net/images/logos/php-logo.svg'); // "<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1 100 50">\n ..."
With $offset
we can set the starting point to read the content, whereas with length
we can get a given amount of bytes.
# Content: "The quick brown fox jumps over the lazy dog"
echo file_get_contents('/tmp/test', offset: 16, size: 3); // 'fox'
offset
also accepts negative values, which counts from the end of the stream.
# Content: "The quick brown fox jumps over the lazy dog"
echo file_get_contents('/tmp/test', offset: -4, size: 3); // 'dog'
Notice that the same rules that govern fseek
are also applied for $offset
, that is - you cannot set an $offset
while reading remote files, as the function would be basically fseek
the stream, and we've seen that it does not work well.
The parameter context
makes file_get_contents
really flexible, enabling us set, for example:
- Set a different HTTP method, such as POST instead of the default GET
- Provide headers and content to a POST or PUT request
- Disable SSL verification and allow self-signed certificates
We create a context using stream_context_create, example:
$context = stream_context_create(['http' => ['method' => "POST"]]);
file_get_contents('https://a-valid-resource.xyz', context: $context);
You can find the list of options you can provide to stream_context_create
in this page.
$networkResource = fopen('https://releases.ubuntu.com/24.04/ubuntu-24.04-desktop-amd64.iso', 'r');
while ($chunk = fread($networkResource, 1024)) {
doSomething($chunk);
}
Which one to use? fread
, file_get_contents
, fgets
, another one?
The list of functions that we can use to read local or remote contents is lengthy, and each function can be seen as a tool in your tool belt, suitable for a specific purpose.
According to the docs, file_get_contents
is the preferred way of reading contents of a file into a string, but, is it appropriate for all purposes?
- What if you know that the content is large? Will it fit into memory?
- Do you need it entirely in memory or can you work in chunks?
- Are you going to work on local or remote files?
Ask yourself these (and other questions), make some performance benchmark tests and select the function that suits your needs the most.
PSR-7's StreamInterface
PSR defines the StreamInterface
, which libraries such as Guzzle use to represent request and response bodies. When you send a request, the body is an instance of StreamInterface
. Let's see an example, extracted from the Guzzle docs:
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', 'http://httpbin.org/get');
$body = $response->getBody();
$body->seek(0);
$body->read(1024);
I suppose that the methods available on $body
look familiar for you now :D
StreamInterface implements methods that resemble a lot the functions we've just seen, such as:
seek()
tell()
eof()
read
write
isSeekable
isReadable()
isWritable
- and so on.
Last but not least, we can use GuzzleHttp\Psr7\Utils::streamFor
to create streams from strings, resources opened with fopen
and instances of StreamInterface
:
use GuzzleHttp\Psr7;
$stream = Psr7\Utils::streamFor('string data');
echo $stream; // string data
echo $stream->read(3); // str
echo $stream->getContents(); // ing data
var_export($stream->eof()); // true
var_export($stream->tell()); // 11
Summary
In this article we've seen what streams really are, learned how to create them, read from them, write to them, manipulate their pointers as well as clarified some obscured parts regarding read a write buffers.
If I did a good job, some of the doubts you might have had regarding streams are now a little bit clearer and, from now on, you'll write code more confidently, as you know what you are doing.
Should you noticed any errors, inaccuracies or there is any topic that is still unclear, let me know in the comments and I'd be glad to try to help.
Top comments (3)
Awesome article. Thank you for sharing your knowledge with the community.
Following you :)
absolutely spectacular. follow earned!
nice article. I am going to give it another read for sure just to get all the details.