Understanding the Memory Profile of PHP Objects
AI generated
<?php
8.4
PHP · Memory Management · Internals
Understanding the Memory Profile of PHP Objects
How much RAM an object really costs, and why empty never means empty

A PHP object without a single property still occupies several dozen bytes in memory, purely from the internal zend_object header. Anyone seeing unexpectedly high memory usage with thousands of objects per request usually finds the cause in the interplay of object header, property table and the size of typed properties, not in the actual payload.

15 min read zend_object · property overhead · memory_get_usage PHP 8.x

1. Why the memory profile of objects matters at all

As long as an application only creates a few hundred objects per request, the exact memory profile of a single PHP object barely matters. But once an import script creates tens of thousands of value objects from a CSV file, an ORM hydrates thousands of entity objects for a collection, or a report job holds millions of small data points as objects, the overhead per object adds up to a real memory problem that either exceeds the memory_limit or puts the garbage collector under continuous load.

The fundamental misconception here: many developers estimate an object's memory requirement by simply adding up the size of its contained values, for example an integer at eight bytes and a short string by its character length. The real memory profile of a PHP object is significantly larger, though, because PHP maintains an internal header, a property table, and a separate zval container for every single property, independent of the actual value.

This article breaks down the memory profile of a PHP object layer by layer: from the zend_object header through the property table to the effects of typed properties. It ends with a practical guide on how to measure actual memory usage and reduce it deliberately within limits, without giving up the advantages objects have over arrays.

2. The zend_object header in detail

Every PHP object is internally represented by a C structure called zend_object. This structure contains, among other things, a reference counter (refcount), a pointer to the associated class definition (zend_class_entry), a handle for the internal object store, and pointers to handler tables that define how the object behaves during comparisons, cloning, or garbage collection. On a typical 64 bit system, this header alone occupies between 40 and 56 bytes, depending on the exact PHP version and build configuration.

The decisive point: this header exists for every single object, regardless of how many properties it has or whether it has any properties at all. An instance of a completely empty class without a single property already costs the full header overhead before a single byte of payload is added. This explains why instantiating many small, specialized objects, for instance one for every single character of a text or every single value in a very large number series, costs a disproportionate amount of memory in practice.

A second, often overlooked part of the memory profile is the shared class definition itself. The zend_class_entry with its method table, property names and type information is only held once per class in memory, regardless of how many instances exist. This structure therefore does not count toward the memory profile of a single instance, but it is quite relevant when looking at the overall memory budget of an application with many different classes.

3. The property table and the cost per property

Beyond the fixed header overhead, every declared property of an object contributes to its memory profile. PHP allocates a table for an object's properties directly following the zend_object header, the so called properties table. Every entry in this table is a zval, a fixed size container holding the property's actual value along with a type tag, regardless of whether the property holds an integer, a string, or another object.

On a typical 64 bit system, a single zval occupies 16 bytes: eight bytes for the actual value or a pointer to more complex data, and additional bytes for type information and internal flags. An object with ten typed properties therefore contributes about 160 bytes to the memory profile through the property table alone, on top of the header overhead of 40 to 56 bytes. With strings, arrays and further objects as property values, the memory requirement of the referenced structure itself adds on top of that, because in these cases the zval only contains a pointer, not the full value.


declare(strict_types=1);

// Measuring the real memory footprint of objects with many properties
final class OrderLine
{
    public function __construct(
        public readonly string $sku,
        public readonly int $quantity,
        public readonly float $unitPrice,
        public readonly string $currency,
        public readonly ?string $note = null,
    ) {}
}

$before = memory_get_usage();

// Allocate 100,000 small objects, five properties each
$lines = [];
for ($i = 0; $i < 100_000; $i++) {
    $lines[] = new OrderLine(
        sku: 'SKU-' . $i,
        quantity: 1,
        unitPrice: 9.99,
        currency: 'EUR',
    );
}

$after = memory_get_usage();
$perObject = ($after - $before) / count($lines);

printf("Memory per object: %.1f bytes\n", $perObject);
// Typically well above the naive sum of scalar values alone,
// because header + property table dominate for small objects

In this example, the real memory usage per object is significantly above the naive sum of scalar values, because the header and property table make up the largest share while the actual payload, a short string and three numbers, only contributes a small fraction. This ratio reverses for larger objects with long strings or nested structures, where the referenced data quickly exceeds the header overhead.

4. zval size and reference counting

The zval container is the smallest storage unit for values in PHP and appears not only in object properties but also in array elements and local variables. For the size analysis of objects it matters that a zval itself has a fixed size, regardless of the type of the value it contains. An integer, a boolean and an object pointer occupy the same amount of space in the zval, the actual difference only arises from the data referenced additionally for more complex types.

For strings, arrays and objects as property values, reference counting comes into play: the zval in the property table only contains a pointer to the actual Zend string or zend_array structure, whose reference counter gets incremented on every further assignment. This means that two objects pointing to the same unchanged string do not hold that string twice in memory, as long as neither reference modifies the value and thereby triggers a copy. This behavior is closely related to copy on write for arrays, but applies in a weaker form to strings as well.

In practice this means: objects with many properties pointing to the same unchanged string, for instance a recurring category name or a status code, cost only the zval overhead itself per additional object, not the full string length again. Only when an object modifies its own, independent copy of a string does copy on write trigger a real, additional memory allocation.

5. Typed properties and their effect on size

Since PHP 7.4, the language supports typed properties, and this has a direct effect on an object's memory profile. A typed property that has not yet received a value exists internally in a special uninitialized state that occupies less memory than a fully initialized zval. As soon as the property is assigned a value, however, it switches into the regular state and occupies the same memory as an untyped property.

The actual advantage of typed properties therefore does not lie primarily in reduced memory usage for initialized objects, but in error prevention: accessing a typed but uninitialized property throws an Error instead of silently returning null, which surfaces bugs much earlier. What is relevant for the memory profile above all is that typed scalar values like int or float continue to be stored via the full zval format in PHP, meaning PHP does not internally allocate more compact, C style structs for typed objects the way a language with a real value type system would.

A practical comparison shows the difference between an object with only public properties and a functionally equivalent associative array. An array needs, for every key, not just a zval but also a hash of the key and a bucket entry in the internal hash table, which tends to create more overhead for many small entries than the property table of an object holding the same values. Objects are therefore often the more memory efficient choice over associative arrays for many small, recurring records, even though both variants transport similar payloads.

6. Inheritance, traits and shared metadata

Inheritance affects the memory profile in two different ways. First, every additional property declared by a parent class enlarges the property table of every instance of a child class, because inherited properties are part of the same contiguous table as the child class's own properties. Second, the method table, unlike properties, remains fully shared at the class level: methods cost no additional memory per instance, regardless of how many methods a class or its parent classes define.

Traits behave similarly to properties and methods declared directly in the class when it comes to the memory profile: the PHP compiler effectively copies trait declarations into the consuming class before the class itself is compiled. Properties from a trait therefore contribute to the property table of every instance just like directly declared properties do, there is no additional overhead created purely by using a trait compared to a direct declaration.

For applications with deep inheritance hierarchies, such as in Magento or Symfony, where base classes frequently bring numerous properties for caching, event handling or configuration, this can add up quickly: a child class with five own properties, whose parent class already declares fifteen properties for internal purposes, produces instances with twenty entries in the property table, even if only five of them are actively used in the actual application code.

7. Measuring memory usage in practice

The most reliable method for measuring the real memory profile of objects in your own application is a direct comparison of memory_get_usage() before and after creating a defined number of instances, as shown in the previous code example. It matters that memory_get_usage(true) with the parameter true returns the memory actually allocated by the system including internal fragmentation, while memory_get_usage() without a parameter only shows the memory PHP reports as used, which is usually the more precise metric for pure object size analysis.

For a more detailed analysis of individual objects, the Xdebug extension function xdebug_debug_zval() is useful, showing the reference counter and internal structure of a single value, along with tools like memory_get_peak_usage() to capture peak consumption during an entire request, not just a snapshot. External profilers such as Blackfire or XHProf additionally provide a breakdown of memory usage per function call, which helps identify exactly the place in the code where an unexpectedly large number of objects is held in memory simultaneously.

A practical pitfall when measuring: the PHP garbage collector does not clean up circular references immediately, but collects them in a separate root buffer and only processes them once this buffer reaches a certain fill level. Anyone measuring memory immediately after deleting object references without explicitly calling gc_collect_cycles() beforehand may see higher memory usage than what is actually finally needed, because not yet collected circular references still occupy memory.


declare(strict_types=1);

// Force garbage collection before measuring released memory,
// otherwise circular references may still hold memory
$before = memory_get_usage();

$objects = [];
for ($i = 0; $i < 50_000; $i++) {
    $objects[] = new OrderLine('SKU-' . $i, 1, 9.99, 'EUR');
}

$peak = memory_get_peak_usage();
unset($objects);

// Without an explicit collection cycle, freed memory may not
// be reflected immediately if circular references exist
gc_collect_cycles();
$after = memory_get_usage();

printf("Peak: %.2f MB, released to: %.2f MB\n", $peak / 1_048_576, $after / 1_048_576);

8. Reducing object memory deliberately

Anyone who finds after measuring that the memory profile of their own objects actually becomes a problem has several practical levers. The first and most effective one: reduce the number of objects held in memory simultaneously, instead of optimizing the size of each individual object. A generator that creates and processes objects one at a time, instead of holding an entire collection as an array in memory, often reduces peak usage by orders of magnitude without changing the object design itself.

The second lever concerns the number of properties itself. Value objects with many rarely used, optional properties can be split into smaller, deliberately composed objects so that only the data actually needed is held in memory, instead of one large object with many unused null properties. Here too: every additional property costs at least one zval entry in the property table, regardless of whether it actually carries a meaningful value in the specific use case.

A third, less obvious lever is the deliberate choice between objects and arrays for very large, homogeneous data volumes. For millions of identical, simple records, for example when processing CSV rows, a flat, typed array with a fixed structure can actually require less memory than the same number of object instances, because the fixed per object header overhead does not apply to arrays. The decision between object and array should therefore not be based purely on readability arguments for very large volumes, but also on the actually measured memory profile.

A direct comparison shows how different data structures compare in memory profile given the same payload.

9. Object types in a memory comparison

Structure Header overhead Overhead per field When it makes sense
Object, 5 properties ~48 bytes zend_object 16 bytes per zval Type safety, methods needed
Associative array, 5 keys ~56 bytes zend_array zval + hash + bucket Dynamic, unknown keys
Numeric array, 5 values ~56 bytes zend_array zval + bucket, no hash Large, homogeneous lists
Generator instead of array No object per element One element at a time Very large data volumes
Inherited base class Header + inherited properties Accumulates across hierarchy Check with deep inheritance

The comparison shows: there is no universally best structure, every choice has its own memory profile with clear advantages and disadvantages. For small, manageable object counts, the difference rarely matters; for bulk processing with hundreds of thousands or millions of instances, the right choice decides over a noticeably different memory requirement.

10. Summary

The memory profile of PHP objects consists of three parts: the fixed zend_object header of about 40 to 56 bytes, the property table with a 16 byte zval per property, and the actual memory requirement of more complex values like strings, arrays or nested objects, which are only referenced. Typed properties barely change this memory requirement in the initialized state, but they reduce the risk of errors through early exceptions on uninitialized access.

Anyone who actually wants to optimize the memory usage of their own application should first measure with memory_get_usage() and memory_get_peak_usage() instead of guessing, and then primarily address the number of objects held simultaneously, for example through generators, instead of the size of individual instances. Knowing the memory profile of individual objects is the foundation of any well grounded decision between objects, arrays and generators for large data volumes.

Memory profile of PHP objects, the essentials at a glance

zend_object header

About 40 to 56 bytes of fixed overhead per instance, regardless of the number of properties.

zval per property

16 bytes per declared property, regardless of type. Strings and objects add an extra reference on top.

Typed properties

Reduce errors through early exceptions, but barely change the size of initialized values.

Measure, don't guess

memory_get_usage() before and after object creation reliably shows the real overhead per instance.

11. FAQ: Memory Profile of PHP Objects

1How much memory does an empty PHP object occupy?
About 40 to 56 bytes purely from the zend_object header, depending on PHP version and build. Arises regardless of whether properties exist.
2How large is a zval in PHP?
16 bytes on 64 bit systems, regardless of type. Every property needs its own zval entry in the property table.
3Do typed properties save memory?
Barely, once initialized. The advantage lies in error prevention through exceptions on uninitialized access, not lower memory usage.
4Objects or arrays: which is more efficient?
Often objects for many small records, because associative arrays need an extra hash and bucket per key.
5How do I measure real object memory?
memory_get_usage() before and after creating a defined number of instances. memory_get_peak_usage() shows the request's peak consumption.
6Does inheritance cost additional memory?
Yes, inherited properties enlarge every instance's property table. Methods stay shared and cost no memory per instance.
7Do traits create memory overhead?
No, the compiler copies trait declarations into the class. Trait properties cost exactly as much as directly declared properties.
8Why use generators instead of arrays?
A generator holds only one element at a time. With millions of objects this reduces peak consumption by orders of magnitude.
9What does gc_collect_cycles do for measurement?
Circular references are collected late otherwise. gc_collect_cycles() forces the cycle immediately for more accurate measurements.
10When is an array worth it over objects for large volumes?
For millions of identical records, because the fixed per object header overhead does not apply to arrays.

Mironsoft

PHP memory profiling, performance audits and refactoring of data intensive processes

Got memory issues with large object volumes under control?

We analyze the memory profile of your data processing, identify unnecessary object overhead and build memory friendly alternatives with generators and deliberately reduced value objects.

Memory profiling

Measuring real overhead via memory_get_usage and peak analysis under production load

Data model review

Evaluating object versus array structures for large, homogeneous data volumes

Import and batch optimization

Converting memory intensive import and report scripts to generators and streaming