====== PHP RFC: CSV Extension (ext/csv) ======
* Version: 1.0
* Date: 2026-10-08
* Author: Damian Jóźwiak, damian.jozwiak.lodz@gmail.com
* Based on: the [[https://gitlab.com/Girgias/csv-php-extension|girgias/csv]] extension by Gina Peter Banyard (BSD-3-Clause)
* Status: Draft
* Implementation: https://github.com/php/php-src/pull/24199
* Discussion thread: tbd
* Voting thread: tbd
This proposal builds on Gina Peter Banyard's ''girgias/csv'' extension. Gina has not reviewed this RFC, and it should not be read as her endorsement.
===== Introduction =====
PHP 8.6 deprecated the CSV methods of ''SplFileObject'' without providing a replacement. This RFC proposes a small CSV extension that follows RFC 4180 conventions by default in the ''Csv\'' namespace that fills that gap and, unlike the existing CSV functions, is locale-independent, has no proprietary escape character and supports multibyte delimiters and enclosures.
PHP's built-in CSV tooling (''fgetcsv()'', ''fputcsv()'', ''str_getcsv()'' and the CSV methods of ''SplFileObject'') predates RFC 4180 adoption in the wider ecosystem and carries behaviour that cannot be fixed without breaking compatibility:
* A proprietary ''$escape'' mechanism incompatible with every other CSV implementation. Relying on its default was deprecated in PHP 8.4 ("Kill proprietary CSV escaping mechanism") and the default becomes ''%%""%%'' in PHP 9.0.
* Locale-dependent parsing: the parser consults ''LC_CTYPE'' through ''mblen()''/''isspace()'', so the same file can parse differently depending on ''setlocale()''.
* Single-byte delimiters and enclosures only.
* An empty line parses as ''[null]''; whitespace before an opening enclosure is silently dropped (''%% "a" ,b%%'' parses as ''["a ", "b"]''); fields containing a space are enclosed on output although RFC 4180 does not require it.
* There is no inverse of ''str_getcsv()''; writing a CSV string requires a ''php://memory'' stream.
PHP 8.6 deprecated ''SplFileObject::fgetcsv()'', ''SplFileObject::fputcsv()'', ''SplFileObject::setCsvControl()'' and ''SplFileObject::getCsvControl()'' ([[rfc:deprecations_php_8_6|Deprecations for PHP 8.6]]) **without providing a replacement**. Two gaps were raised on the mailing list during that discussion and remain open:
- ''SplFileObject::fputcsv()'' has no drop-in replacement: the procedural ''fputcsv()'' requires a stream resource, and ''SplFileObject'' does not expose its underlying stream.
- ''SplFileObject::READ_CSV'' was not deprecated, yet ''setCsvControl()'', the only way to configure the dialect it uses, was.
This RFC proposes to close those gaps by adding a small, RFC 4180 compliant CSV extension to the core, based on the existing ''girgias/csv'' extension, extended with file/stream support.
===== Proposal =====
Add a new, always-enabled extension ''ext/csv'' providing six functions and one class in the ''Csv\'' namespace. With the default dialect, they follow RFC 4180 conventions: fields containing the delimiter, the enclosure, CR, LF or the EOL sequence are enclosed; enclosures inside enclosed fields are escaped by doubling; there is **no escape character**. Delimiters, enclosures and EOL sequences may be multibyte, and parsing is binary safe and locale-independent.
==== String and row conversion ====
''array_to_row()'' formats one row (the inverse of ''str_getcsv()'', which PHP currently lacks); ''row_to_array()'' parses one row.
==== Collection conversion ====
''collection_to_buffer()'' accepts any ''iterable'' of arrays (including generators) and checks that every row has the same number of fields, throwing a ''ValueError'' otherwise, as does ''buffer_to_collection()'' when parsing. The ''_lax'' variant permits rows of varying width.
==== File and stream handling ====
''collection_to_file()'' writes rows one at a time through the streams layer, so a generator-backed collection of any size is written in constant memory. ''$file'' accepts anything the streams layer accepts (paths, ''php://'', ''compress.zlib://'', user wrappers). Failures to open, write or finish writing (e.g. a failing flush of a compressed stream on close) throw.
''LazyLaxCollection'' iterates rows lazily. ''createFromFile()'' opens and holds a //private// stream: rows are read in chunks with an enclosure-aware scanner (an EOL sequence inside an enclosed field does not terminate a row), and the internal window buffer is compacted as rows are consumed, so memory usage is proportional to the longest row rather than to the file. The stream is owned exclusively by the object (it is not registered as a userland-visible resource) and is closed when the object is destroyed. Iteration can be restarted (''foreach'' twice), including after an early ''break'', as long as the underlying stream is seekable.
==== Error handling ====
* ''ValueError'' for invalid dialect arguments (empty delimiter/enclosure/EOL, delimiter identical to enclosure or to the EOL sequence), for an enclosure inside a non-enclosed field, and for inconsistent row widths in the strict functions.
* ''TypeError'' when a collection element is not an array, or a field is not stringable.
* ''Error'' for I/O failures (open, read, write, flush-on-close) and for rewinding a non-seekable stream.
* No function returns ''false''; end of iteration is expressed by the iterator protocol.
==== Migration from the deprecated SplFileObject API ====
^ Deprecated (8.6) ^ Replacement ^
| ''new SplFileObject($f)'' + ''READ_CSV'' + flags | ''Csv\LazyLaxCollection::createFromFile($f, ...)'' |
| ''SplFileObject::fgetcsv()'' in a loop | ''foreach (Csv\LazyLaxCollection::createFromFile(...))'' |
| ''SplFileObject::fputcsv()'' in a loop | ''Csv\collection_to_file($f, $rows, ...)'' |
| ''SplFileObject::setCsvControl()'' | dialect arguments of the calls above |
| ''str_getcsv()'' (no inverse) | ''Csv\row_to_array()'' / ''Csv\array_to_row()'' |
Note the deliberate semantic differences from the legacy API: no escape character (in line with the PHP 9.0 default), no ''[null]'' for empty lines, no locale dependence, an enclosure inside a non-enclosed field is an error rather than literal data, whitespace before an enclosure is not silently dropped, exceptions instead of ''false'', and ''"\r\n"'' as the default EOL sequence as specified by RFC 4180.
==== Examples ====
Round trip through a string, with enclosed fields containing the enclosure and a line break:
Multibyte delimiter, enclosure and EOL sequence:
Edge cases: strict and lax row widths, and errors:
getMessage(), "\n";
// Buffer row 2 contains 1 fields compared to 2 fields on previous rows
}
var_dump(Csv\buffer_to_collection_lax("a,b\r\nc\r\n")); // [['a', 'b'], ['c']]
var_dump(str_getcsv('', escape: '')); // [null]
var_dump(Csv\row_to_array('')); // ['']
try {
Csv\LazyLaxCollection::createFromFile('/nonexistent/file.csv');
} catch (Error $e) {
echo $e->getMessage(), "\n";
// Failed to open "/nonexistent/file.csv" for reading
}
?>
===== Backward Incompatible Changes =====
No existing built-in function, class or constant is changed. However, the ''Csv\'' top-level namespace would become reserved for this extension, following the [[rfc:namespaces_in_bundled_extensions|policy on namespaces in bundled extensions]]; userland code declaring symbols in ''Csv\'' may conflict, as with the ''Random\'' and ''Uri\'' namespaces before it.
===== Proposed PHP Version(s) =====
Next minor PHP version (the release after PHP 8.6).
===== RFC Impact =====
==== To the Ecosystem ====
No new syntax. IDEs, language servers and static analysers need stubs for the new functions and class, which can be taken from ''ext/csv/csv.stub.php''. Userland CSV libraries are unaffected.
==== To Existing Extensions ====
None. ''ext/standard'' and ''ext/spl'' are unchanged.
==== To SAPIs ====
None beyond a new always-enabled extension.
==== New Constants ====
None.
==== php.ini Defaults ====
None.
===== Open Issues =====
* Whether the extension should be disableable at build time. The implementation currently supports ''--disable-csv''; the proposal is to make it always enabled, like ''ext/random'' and ''ext/uri'', since a migration target for a deprecated core API must be reliably present.
* Naming of ''LazyLaxCollection'' and whether a strict lazy variant should ship in the first version.
===== Unaffected PHP Functionality =====
''fgetcsv()'', ''fputcsv()'', ''str_getcsv()'' and the deprecated ''SplFileObject'' CSV methods keep their current behaviour. This RFC neither changes nor deprecates them.
===== Future Scope =====
* A deprecation timeline for the non-compliant ''str_getcsv()'', ''fputcsv()'' and ''fgetcsv()'' functions, as previously outlined by the ''girgias/csv'' project. Deliberately **not** part of this RFC.
* Deprecation of ''SplFileObject::READ_CSV'' in a separate follow-up RFC once this replacement exists, resolving the inconsistency left by the 8.6 deprecations.
* Header handling (mapping rows to associative arrays), encoding/BOM handling, and further convenience APIs, which can be built in userland on top of the provided primitives.
* **Object-oriented Reader/Writer API.** A separate future RFC may introduce dedicated Csv\Reader and Csv\Writer classes with capability-oriented interfaces (such as Csv\Readable and Csv\Writable) to support dependency injection, testability, and alternative implementations. The exact contracts and naming would be decided in that future RFC. The current proposal deliberately limits its scope to procedural functions and the stateful iterator; it does not reject object-oriented design.
===== Voting Choices =====
Please consult [[https://github.com/php/policies/blob/main/feature-proposals.rst#voting-phase|the php/policies repository]] for the current voting guidelines.
Voting has not started. If the RFC proceeds to a vote after the required discussion period, the proposed primary question is:
* Add the CSV extension (''ext/csv'') to PHP core as described in this RFC? (Yes/No; 2/3 majority required.)
The voting widget and dates will be added when voting is formally announced.
===== Patches and Tests =====
Implementation targeting php-src ''master'': ''ext/csv'', a port of ''girgias/csv'' 0.6.0 (BSD-3-Clause, copyright Gina Peter Banyard and Contributors) with the new ''collection_to_file()'' and ''LazyLaxCollection::createFromFile()'' APIs.
* Branch: https://github.com/damek24/php-src/tree/rfc-csv
* 78 phpt tests, including regression tests for sparse collections, multibyte dialect tokens at read-chunk boundaries, iterator cleanup on error paths, and stream lifecycle (private stream ownership, flush-on-close failures).
* Six bugs present in ''girgias/csv'' 0.6.0 were found and fixed during the port; they will be reported upstream.
Disclosure: parts of the implementation were written with the assistance of an LLM. The author has reviewed all of it and takes full responsibility for it.
===== Implementation =====
* Pull request: https://github.com/php/php-src/pull/24199
* Development branch: https://github.com/damek24/php-src/tree/rfc-csv
===== References =====
* RFC 4180: https://www.rfc-editor.org/rfc/rfc4180
* ''girgias/csv'' extension: https://gitlab.com/Girgias/csv-php-extension (PECL/PIE: ''girgias/csv'')
* [[rfc:deprecations_php_8_6|Deprecations for PHP 8.6]] (SplFileObject CSV methods)
* [[rfc:deprecations_php_8_4|Deprecations for PHP 8.4]] (proprietary CSV escaping)
* Mailing list discussion of the 8.6 deprecation and its migration gaps: https://discourse.thephp.foundation/t/re-php-dev-rfc-deprecations-for-php-8-6/5636 and https://discourse.thephp.foundation/t/php-dev-re-fwd-rfc-deprecations-for-php-8-6/5939
===== Rejected Features =====
* **An object-oriented Reader/Writer API in this RFC.** A comprehensive OO API, including instance-based readers/writers and possible interfaces, deserves separate design and review. Adding it here would expand the scope and public API before those contracts have been discussed. The ''LazyLaxCollection'' class is included now because lazy iteration requires holding state; OO APIs remain possible future work.
* **Compatibility with the legacy ''fgetcsv()'' dialect** (escape character, ''[null]'' rows, locale dependence). Migrating code is expected to adopt the standard-compliant behaviour rather than carry the legacy quirks forward.
===== Changelog =====
* 1.0 (2026-10-08): Initial draft prepared for publication; discussion and voting have not started.