PONYλM2Modula-2

C.CodeCompared.To/PHP

An interactive executable cheatsheet comparing C and PHP

C17 (GCC) PHP 8.3.12 (Wandbox), 8.3.11 (Judge0) or 8.5.8 (in-browser)
Hello, World & Output
Hello, World
There is no main and nothing to link: the file is the program and its statements run top to bottom. The <?php tag is not decoration — a PHP file is a template that happens to contain code, and everything outside the tag is copied to the output unchanged.
#include <stdio.h> int main(void) { printf("Hello, World!\n"); return 0; }
<?php echo "Hello, World!\n";
echo is a language construct rather than a function, so it takes no parentheses, returns nothing, and adds no newline — which makes it closer to fputs(stdout, …) than to puts. The closing ?> tag is deliberately left off every file on this page: a stray blank line after it would be sent to the client and break a header, so omitting it is the convention everywhere.
printf Came Across Unchanged
This is the row that tells you where PHP came from. The conversion specifiers are C’s, the padding and precision syntax is C’s, and sprintf returns the string instead of filling a buffer you sized.
#include <stdio.h> int main(void) { const char *name = "Ada"; int count = 3; double average = 2.5; printf("%s has %d items averaging %.2f\n", name, count, average); printf("%-10s|%10s|\n", "left", "right"); printf("%05d %x %o %e\n", 42, 255, 8, 1234.5); return 0; }
<?php $name = "Ada"; $count = 3; $average = 2.5; printf("%s has %d items averaging %.2f\n", $name, $count, $average); printf("%-10s|%10s|\n", "left", "right"); printf("%05d %x %o %e\n", 42, 255, 8, 1234.5);
The two columns print the same three lines. One difference shows up in the output: C pads the exponent to two digits, so %e gives 1.234500e+03 where PHP gives 1.234500e+3. The rest are small and worth knowing: PHP adds %b for binary and %'x10d for an arbitrary pad character, drops the length modifiers (%ld, %zu) because there is only one integer type, and — crucially — printf is a real function with a variable argument list that it can actually count, so passing too few arguments is an ArgumentCountError rather than a read of whatever was on the stack.
The Mistake Is Found While Running
A C build checks the whole program before any of it runs. PHP compiles each file to bytecode as it is first reached, so a syntax error in a file is found when that file is included and a type error is found when that line executes.
#include <stdio.h> int main(void) { int count = 3; /* count(); would not compile: error: called object 'count' is not a function The program never starts. */ printf("count is %d\n", count); return 0; }
<?php declare(strict_types=1); $count = 3; echo "count is $count\n"; function needsAnInteger(int $value): int { return $value * 2; } try { needsAnInteger("not a number"); } catch (TypeError $error) { echo "TypeError: ", explode(", called in", $error->getMessage())[0], "\n"; } if (false) { thisFunctionDoesNotExist(); // never looked up, never complained about } echo "still running\n";
The check that does happen is worth the space it takes: with declare(strict_types=1) at the top of the file, a declared parameter type is enforced at the call site and a mismatch throws immediately, naming the caller. Without that line PHP coerces instead — "3" would become 3 — so the declaration is worth roughly nothing. Put it at the top of every file.
Text Outside the Tags Is Output
This has no C counterpart at all. The parser starts in "copy everything through" mode and only starts executing when it sees <?php — which is why PHP’s original job was writing HTML, and why a file with no tag is a valid program that prints itself.
#include <stdio.h> int main(void) { /* The only way to emit literal text is to write it as a string and call something that prints it. */ int count = 2; printf("Report\n"); printf("Items: %d\n", count); printf("End of report\n"); return 0; }
Report <?php $count = 2; ?> Items: <?= $count ?> <?php echo "End of report\n";
The <?= … ?> form is shorthand for <?php echo … ?>, and one newline immediately after a closing tag is swallowed so that templates do not fill up with blank lines — which is why the example has a blank line after the <?= $count ?>: the first newline is eaten and the second is the one that reaches the output. This is a genuinely different execution model from anything in C: the program is a document with holes in it, and the holes are evaluated in order.
Variables Without Declarations
Every Variable Starts With $ and Is Never Declared
There is no declaration and no type on the left of the assignment. The $ is what makes that unambiguous to the parser — it is how PHP can tell a variable from a function name or a constant without a symbol table built from declarations.
#include <stdio.h> int main(void) { int count = 42; double ratio = 2.5; const char *label = "answer"; printf("%d %.1f %s\n", count, ratio, label); return 0; }
<?php $count = 42; $ratio = 2.5; $label = "answer"; echo "$count $ratio $label\n"; echo gettype($count), " ", gettype($ratio), " ", gettype($label), "\n";
The $ also makes interpolation possible: "$count items" substitutes the variable inside a double-quoted string, which is why the C column needs printf and the PHP column does not. Single quotes turn interpolation off, so '$count' is the two characters. There is one namespace for variables and a separate one for functions, so $count and count() never collide.
Type Declarations Are Optional, and Enforced Where You Write Them
You cannot type a variable, but you can type a parameter, a return value, and a class property — and unlike some dynamic languages that added annotations, PHP actually checks them at run time.
#include <stdio.h> /* Checked once, at compile time, and then not at all. */ static int doubled(int value) { return value * 2; } int main(void) { printf("%d\n", doubled(21)); /* doubled("text"); does not compile. */ return 0; }
<?php declare(strict_types=1); function doubled(int $value): int { return $value * 2; } echo doubled(21), "\n"; try { doubled("21"); // strict_types: no coercion, even from a numeric string } catch (TypeError $error) { echo "rejected: ", explode(", called in", $error->getMessage())[0], "\n"; } function describe(int|string $value): string { return is_int($value) ? "int $value" : "string $value"; } echo describe(1), " / ", describe("one"), "\n";
The check happens on every call, so it costs something — but it turns a class of silent coercions into an exception at the boundary, which is the nearest thing to a compiler PHP has. Union types (int|string), nullable types (?int), and never are all spellable. Without strict_types, that doubled("21") would quietly succeed and return 42.
Constants Instead of #define
C’s #define is textual substitution before compilation, so it has no type, no scope, and no existence at run time. PHP’s const and define() create real named values — and define(), unlike const, can take a name computed while running.
#include <stdio.h> #define MAXIMUM_RETRIES 3 #define GREETING "hello" /* An enum is the typed alternative for a set of related values. */ enum Level { LEVEL_LOW = 1, LEVEL_HIGH = 9 }; int main(void) { printf("%d %s %d\n", MAXIMUM_RETRIES, GREETING, LEVEL_HIGH); return 0; }
<?php const MAXIMUM_RETRIES = 3; const GREETING = "hello"; define("COMPUTED_" . strtoupper("name"), "made at run time"); enum Level: int { case Low = 1; case High = 9; } echo MAXIMUM_RETRIES, " ", GREETING, " ", Level::High->value, "\n"; echo COMPUTED_NAME, "\n"; echo "defined? ", var_export(defined("MAXIMUM_RETRIES"), true), "\n";
Constants have no $, which is how the parser tells them from variables, and they are not interpolated into strings. The enum is the closer match to C’s: it is a real type, so a function can declare it as a parameter type and nothing else will be accepted — where a C enum is an int in a hat and any integer fits in it.
static Inside a Function Means Exactly What It Does in C
Of all the things PHP borrowed, this is the least changed: a static local is initialized once, keeps its value between calls, and is invisible outside the function.
#include <stdio.h> static int next_identifier(void) { static int counter = 0; return ++counter; } int main(void) { printf("%d %d %d\n", next_identifier(), next_identifier(), next_identifier()); return 0; }
<?php function nextIdentifier(): int { static $counter = 0; return ++$counter; } echo nextIdentifier(), " ", nextIdentifier(), " ", nextIdentifier(), "\n";
The C column’s output order is not guaranteed, incidentally — the order in which printf’s arguments are evaluated is unspecified, so this exact program may print 1 2 3 or 3 2 1 depending on the compiler. PHP specifies left-to-right evaluation, so its column always prints 1 2 3. That is a small example of a large theme: several things C leaves to the implementation, PHP simply defines.
A Variable Whose Name Is Computed
C resolves every name at compile time; there is no run-time symbol table to consult. PHP keeps one, so a variable can be reached by a name held in another variable — the feature that most clearly marks it as an interpreted language.
#include <stdio.h> #include <string.h> int main(void) { /* The nearest C equivalent is a lookup table you build yourself. */ const char *names[] = { "width", "height" }; int values[] = { 10, 20 }; const char *wanted = "height"; for (int index = 0; index < 2; index++) { if (strcmp(names[index], wanted) == 0) { printf("%s = %d\n", wanted, values[index]); } } return 0; }
<?php $width = 10; $height = 20; $wanted = "height"; echo "$wanted = ", $$wanted, "\n"; // a variable variable // The same idea, spelled the way modern code actually does it: $dimensions = ["width" => 10, "height" => 20]; echo "$wanted = ", $dimensions[$wanted], "\n";
Variable variables exist, they work, and they are almost always the wrong answer — an array is clearer, faster and greppable, which is why the second half of the column is what you will actually see in a codebase. The reason to know the feature is that it explains why PHP can do things C cannot: names are looked up in a hash table while the program runs, which is the same mechanism behind $$name, $object->$property and $function().
Integers That Refuse to Wrap
An Integer That Overflows Becomes a Float
This is the most surprising numeric difference. There is one integer type, int, and it is the platform word — 64 bits everywhere that matters. When an operation would exceed it, the result is not wrapped and is not an error: it silently becomes a float.
#include <stdio.h> #include <limits.h> #include <inttypes.h> int main(void) { long biggest = LONG_MAX; printf("max %ld\n", biggest); /* Signed overflow is undefined behavior, so the wrap has to be done in unsigned arithmetic to be legal at all. */ unsigned long wrapped = (unsigned long)biggest + 1UL; printf("wrapped %lu\n", wrapped); printf("width %zu bytes\n", sizeof(long)); return 0; }
<?php echo "max ", PHP_INT_MAX, "\n"; $promoted = PHP_INT_MAX + 1; echo "promoted ", $promoted, "\n"; echo "type ", gettype($promoted), "\n"; echo "width ", PHP_INT_SIZE, " bytes\n"; // Precision is lost in the promotion, exactly as it would be in a double: echo "still equal to max? ", var_export($promoted == PHP_INT_MAX + 2, true), "\n";
The promotion keeps the magnitude and loses the exactness — a float has 53 bits of significand, so past 2⁵³ consecutive integers stop being distinguishable, which is what the last line demonstrates. It is safer than C’s undefined behavior and worse than C’s defined unsigned wrap, and the practical rule is the same one you already use: if the value can be large, check before you multiply. intdiv throws on overflow rather than promoting, which is the one place PHP prefers an error.
/ Always Gives a Float; intdiv Gives the C Answer
C picks integer or floating division from the operand types. PHP’s / always produces a float when the division is not exact, so the truncating division you expect has its own function.
#include <stdio.h> int main(void) { printf("7 / 2 = %d\n", 7 / 2); printf("7.0 / 2.0 = %.1f\n", 7.0 / 2.0); printf("-17 / 5 = %d\n", -17 / 5); printf("-17 %% 5 = %d\n", -17 % 5); return 0; }
<?php echo "7 / 2 = ", 7 / 2, "\n"; echo "intdiv = ", intdiv(7, 2), "\n"; echo "-17 / 5 = ", intdiv(-17, 5), "\n"; echo "-17 % 5 = ", -17 % 5, "\n"; try { echo intdiv(1, 0); } catch (DivisionByZeroError $error) { echo "1/0 throws DivisionByZeroError\n"; }
The signs agree: both truncate toward zero, so -17 / 5 is −3 and -17 % 5 is −2 in each column. Division by zero is where they part company — undefined behavior in C, typically a SIGFPE, and a catchable DivisionByZeroError here. Note also that % casts its operands to int first, so 7.5 % 2 is 1, not 1.5; fmod is the floating-point remainder.
There Is No Unsigned Type
Every PHP integer is signed. That removes the entire family of signed/unsigned comparison surprises you know from C, and it also removes the ability to represent the top half of a 64-bit range — which matters at exactly one place, reading binary data.
#include <stdio.h> #include <stdint.h> #include <inttypes.h> int main(void) { unsigned int large = 4000000000u; printf("as unsigned %u\n", large); printf("as signed %d\n", (int)large); /* The classic trap: a signed/unsigned comparison converts the signed operand, so this is FALSE. */ int negative = -1; unsigned int zero = 0u; printf("-1 < 0u ? %d\n", negative < zero); return 0; }
<?php $large = 4000000000; // fits: PHP ints are 64-bit and signed echo "as int ", $large, "\n"; echo "as 32-bit ", $large - 4294967296, "\n"; echo "-1 < 0 ? ", var_export(-1 < 0, true), "\n"; // Reading a 32-bit unsigned value out of bytes stays exact, // because a 64-bit signed integer has room for all of it: $bytes = pack("N", 4000000000); echo "unpacked ", unpack("N", $bytes)[1], "\n";
The C column’s last line prints 0, which is correct and almost never what the author wanted: -1 converts to a very large unsigned value before the comparison. PHP has no way to write that bug. The cost is at 64 bits: unpack("J", …) on a value above 2⁶³ comes back negative, and there is nothing wider to put it in short of a string.
The Bitwise Operators Are C’s
Same spellings, same meanings, same precedence surprises, and they operate on the full 64-bit integer rather than truncating. This is the row where nothing changed.
#include <stdio.h> #define FLAG_READ 0x01 #define FLAG_WRITE 0x02 #define FLAG_EXEC 0x04 int main(void) { int flags = FLAG_READ | FLAG_WRITE; printf("flags %d\n", flags); printf("has write? %d\n", (flags & FLAG_WRITE) != 0); flags &= ~FLAG_WRITE; printf("after clear %d\n", flags); printf("shifted %ld\n", 1L << 40); /* 1 << 40 on an int is UB */ printf("binary %d%d%d\n", (flags >> 2) & 1, (flags >> 1) & 1, flags & 1); return 0; }
<?php const FLAG_READ = 0x01; const FLAG_WRITE = 0x02; const FLAG_EXEC = 0x04; $flags = FLAG_READ | FLAG_WRITE; echo "flags ", $flags, "\n"; echo "has write? ", var_export(($flags & FLAG_WRITE) !== 0, true), "\n"; $flags &= ~FLAG_WRITE; echo "after clear ", $flags, "\n"; echo "shifted ", 1 << 40, "\n"; printf("binary %03b\n", $flags);
Both columns print a trillion, but the C one has to say 1L to get there: shifting an int by 40 is undefined behavior, and this program printed 177873260 before the L was added. PHP has no narrower integer to fall off, so its shift is always the 64-bit one. Shifting by a negative amount throws ArithmeticError and shifting by 64 or more gives zero, both of which are also undefined behavior in C. The %b conversion in the last line has no C counterpart and saves the hand-rolled bit extraction the other column needs.
A Counted Byte String
A Counted Byte String, With No Terminator
This is the representation you would design to replace char *: a length and a buffer. It is still bytes — not characters, not UTF-16 — so everything you know about encoding still applies, but the zero byte is now ordinary data.
#include <stdio.h> #include <string.h> int main(void) { char text[] = "safe\0evil"; printf("strlen says %zu\n", strlen(text)); printf("printed: %s\n", text); printf("array is %zu bytes\n", sizeof text); return 0; }
<?php $text = "safe\0evil"; echo "strlen says ", strlen($text), "\n"; echo "printed: ", str_replace("\0", "<NUL>", $text), "\n"; echo "has a NUL: ", var_export(str_contains($text, "\0"), true), "\n";
The C column stops at the zero byte and reports 4; PHP reports 9, because the length was stored when the string was built. This is what "binary-safe" means in the PHP manual, a phrase that appears on nearly every string function: it will not stop early at a zero byte. The practical consequence is that file_get_contents on a JPEG gives you the whole file in a string, and every byte of it is addressable.
You Can Write Into a String
Unlike most languages with a string type, PHP lets you subscript a string for writing. It behaves like your own char array — assign a byte at an offset, and assigning past the end pads with spaces rather than failing.
#include <stdio.h> int main(void) { char editable[16] = "hello"; editable[0] = 'H'; printf("%s\n", editable); printf("third byte is %c\n", editable[2]); /* Writing past the array is undefined behavior — the buffer size is yours to respect. */ return 0; }
<?php $editable = "hello"; $editable[0] = "H"; echo $editable, "\n"; echo "third byte is ", $editable[2], "\n"; $editable[8] = "!"; // grows, padding with spaces echo "[", $editable, "]\n"; echo "length now ", strlen($editable), "\n";
It is a byte offset, not a character offset, so writing into a multi-byte character corrupts it exactly as it would in C. The difference from C is that the string grows rather than smashing whatever came after it, and that reading past the end gives a warning and an empty string rather than undefined behavior. Negative offsets count from the end, which C has no spelling for at all.
The str Family, Renamed but Recognizable
Almost every C string function has a counterpart with the same job and a slightly different name. The consistent change is that nothing takes a destination buffer: results are returned, so there is no size to compute and nothing to overflow.
#include <stdio.h> #include <string.h> int main(void) { const char *text = "the quick brown fox"; printf("length %zu\n", strlen(text)); printf("find %ld\n", (long)(strstr(text, "quick") - text)); char copy[32]; strncpy(copy, text + 4, 5); copy[5] = '\0'; printf("substr %s\n", copy); char upper[32]; size_t index = 0; for (; text[index] != '\0' && index < sizeof upper - 1; index++) { upper[index] = (char)(text[index] >= 'a' && text[index] <= 'z' ? text[index] - 32 : text[index]); } upper[index] = '\0'; printf("upper %s\n", upper); return 0; }
<?php $text = "the quick brown fox"; echo "length ", strlen($text), "\n"; echo "find ", strpos($text, "quick"), "\n"; echo "substr ", substr($text, 4, 5), "\n"; echo "upper ", strtoupper($text), "\n"; echo "replace ", str_replace("brown", "red", $text), "\n"; echo "split ", implode("|", explode(" ", $text)), "\n";
The one trap in the family is strpos: it returns false when the needle is absent and 0 when the needle is at the start, and false == 0 is true. Always compare with !== false, or use str_contains, which was added for exactly this reason. Note also that these are all byte-oriented, so strtoupper only handles ASCII — the accent-aware version lives in the mbstring extension.
Multi-Line Text Without Escaping
C has no multi-line string literal: you concatenate adjacent literals and put \n at the end of each. A heredoc is a block of text that ends at a label, with interpolation still working inside it.
#include <stdio.h> int main(void) { const char *name = "Ada"; int count = 3; printf("Dear %s,\n" " You have %d items waiting.\n" " Regards,\n" " The System\n", name, count); return 0; }
<?php $name = "Ada"; $count = 3; echo <<<LETTER Dear $name, You have $count items waiting. Regards, The System LETTER; echo "\n";
The indentation of the closing label is stripped from every line, so the block can sit at the indentation of the code around it without that whitespace ending up in the output. The <<<'LABEL' variant, with the label quoted, turns interpolation off and is the equivalent of a raw string — useful when the text contains $.
One Container for Everything
One Container Is the Array, the Struct and the Hash Table
A PHP array is an ordered map from integer-or-string keys to values. Used with consecutive integer keys it reads like a C array; used with string keys it is the hash table you would have written; and it remembers insertion order either way.
#include <stdio.h> #include <string.h> struct Entry { const char *key; int value; }; int main(void) { /* Three separate constructs for three jobs. */ int numbers[3] = { 10, 20, 30 }; struct Entry table[2] = { { "apples", 3 }, { "pears", 7 } }; printf("numbers[1] %d\n", numbers[1]); for (int index = 0; index < 2; index++) { if (strcmp(table[index].key, "pears") == 0) { printf("pears %d\n", table[index].value); } } return 0; }
<?php $numbers = [10, 20, 30]; $counts = ["apples" => 3, "pears" => 7]; echo "numbers[1] ", $numbers[1], "\n"; echo "pears ", $counts["pears"], "\n"; // The same container, mixed: $mixed = [0 => "first", "name" => "Ada", 5 => "sparse"]; foreach ($mixed as $key => $value) { echo " ", var_export($key, true), " => $value\n"; } echo "count ", count($mixed), "\n";
Under the hood this is a hash table with a separate ordered list of buckets, so iteration order is insertion order, not key order — a guarantee C’s hand-rolled tables usually do not make. The cost is that a "list" of a million integers is a hash table of a million integers, roughly an order of magnitude more memory than int[1000000]. When that matters, SplFixedArray in the stdlib section is the real array.
Assigning an Array Copies It
This is the difference that catches out programmers coming from almost anywhere. A PHP array has value semantics: assigning it, passing it to a function, or returning it gives the other side an independent copy — not a pointer, and not a shared reference.
#include <stdio.h> static void modify(int *values) { values[0] = 99; /* the caller sees this */ } int main(void) { int numbers[3] = { 1, 2, 3 }; int *alias = numbers; /* decays to a pointer: shared */ alias[0] = 50; printf("after alias %d\n", numbers[0]); modify(numbers); printf("after modify %d\n", numbers[0]); return 0; }
<?php function modify(array $values): void { $values[0] = 99; // modifies the copy only } $numbers = [1, 2, 3]; $copy = $numbers; // a copy, not an alias $copy[0] = 50; echo "after copy ", $numbers[0], "\n"; modify($numbers); echo "after modify ", $numbers[0], "\n"; function modifyByReference(array &$values): void { $values[0] = 99; // & is how you ask for the pointer back } modifyByReference($numbers); echo "by reference ", $numbers[0], "\n";
The copy is lazy — the engine shares the buffer and clones it only when one side writes, so passing a large array to a function that only reads it costs nothing. The & in the parameter list is the closest thing PHP has to the pointer you would pass in C, and it is declared once at the function rather than at every call. Objects are the exception: a variable holding an object holds a handle, so objects alias exactly as C pointers do.
Growing an Array Without realloc
The capacity-tracking, doubling, realloc-and-check loop that appears in every C program has no counterpart: $array[] = value appends, and the engine does the growing.
#include <stdio.h> #include <stdlib.h> int main(void) { size_t capacity = 2, count = 0; int *numbers = malloc(capacity * sizeof *numbers); if (numbers == NULL) return 1; for (int value = 1; value <= 5; value++) { if (count == capacity) { capacity *= 2; int *grown = realloc(numbers, capacity * sizeof *numbers); if (grown == NULL) { free(numbers); return 1; } numbers = grown; } numbers[count++] = value * value; } for (size_t index = 0; index < count; index++) { printf("%d ", numbers[index]); } printf("\n"); free(numbers); return 0; }
<?php $numbers = []; for ($value = 1; $value <= 5; $value++) { $numbers[] = $value * $value; } echo implode(" ", $numbers), "\n"; echo "count ", count($numbers), "\n"; array_pop($numbers); echo "after pop, count ", count($numbers), "\n";
The keys assigned by [] are the next unused integer, so removing an element from the middle with unset leaves a gap and the array stops being a clean list. array_values renumbers it. That distinction matters when the array is encoded as JSON: a gapped array becomes a JSON object with numeric keys rather than an array, which is a bug that reliably surfaces at the far end of an API.
The Array Functions Replace the Loops
Filtering, mapping and accumulating are three hand-written loops in C. Here they are library functions taking a callback — the same shape as qsort’s comparator, applied to everything.
#include <stdio.h> int main(void) { int numbers[] = { 3, 1, 4, 1, 5, 9, 2, 6 }; size_t count = sizeof numbers / sizeof numbers[0]; int total = 0, matches = 0; for (size_t index = 0; index < count; index++) { if (numbers[index] % 2 == 0) { total += numbers[index] * numbers[index]; matches++; } } printf("%d even values, squares total %d\n", matches, total); return 0; }
<?php $numbers = [3, 1, 4, 1, 5, 9, 2, 6]; $evenSquares = array_map( fn(int $value): int => $value * $value, array_filter($numbers, fn(int $value): bool => $value % 2 === 0) ); $total = array_sum($evenSquares); echo count($evenSquares), " even values, squares total $total\n"; echo "max ", max($numbers), ", min ", min($numbers), ", unique ", count(array_unique($numbers)), "\n";
array_filter preserves the original keys, so the result is a gapped array — which is why array_map here still works but implode on a filtered result can surprise you. The argument order is famously inconsistent across the family (array_map(callback, array) against array_filter(array, callback)), a wart left over from the library growing organically in C.
usort Is qsort With the Casts Removed
The comparator contract is identical — negative, zero, positive — and so is the idea of passing it to the sort. What is gone is the const void * parameters, the casts, and the element size argument.
#include <stdio.h> #include <stdlib.h> static int compare_integers(const void *left, const void *right) { int first = *(const int *)left; int second = *(const int *)right; return (first > second) - (first < second); } int main(void) { int numbers[] = { 10, 9, 1, 100, 20 }; size_t count = sizeof numbers / sizeof numbers[0]; qsort(numbers, count, sizeof numbers[0], compare_integers); for (size_t index = 0; index < count; index++) { printf("%d ", numbers[index]); } printf("\n"); return 0; }
<?php $numbers = [10, 9, 1, 100, 20]; usort($numbers, fn(int $left, int $right): int => $left <=> $right); echo implode(" ", $numbers), "\n"; // sort() knows how to compare numbers already: $again = [10, 9, 1, 100, 20]; sort($again); echo implode(" ", $again), "\n";
The <=> operator is the comparator written as punctuation: it returns −1, 0 or 1, and it handles numbers, strings and arrays. Note that usort sorts in place and returns a boolean, like qsort returning nothing — the value you want is in the variable you passed, which is why it takes its array by reference. sort also discards the keys; asort keeps them.
Functions, References, and Closures
&$parameter Instead of a Pointer
Everything is passed by value, so the C habit of passing an address to let a function write back has a direct replacement: declare the parameter with & and the caller’s variable is what the function sees.
#include <stdio.h> static void swap(int *left, int *right) { int held = *left; *left = *right; *right = held; } static int divide(int numerator, int denominator, int *quotient) { if (denominator == 0) return 0; *quotient = numerator / denominator; return 1; } int main(void) { int first = 1, second = 2; swap(&first, &second); printf("%d %d\n", first, second); int quotient = 0; if (divide(7, 2, &quotient)) { printf("quotient %d\n", quotient); } return 0; }
<?php function swap(int &$left, int &$right): void { [$left, $right] = [$right, $left]; } function divide(int $numerator, int $denominator, ?int &$quotient): bool { if ($denominator === 0) return false; $quotient = intdiv($numerator, $denominator); return true; } $first = 1; $second = 2; swap($first, $second); echo "$first $second\n"; $quotient = null; if (divide(7, 2, $quotient)) { echo "quotient $quotient\n"; }
The & is declared once, at the function, and the call site looks like an ordinary call — which is the opposite of C#’s choice and the same as C++’s. That means you cannot tell from a call whether an argument might be modified, and it is why the modern style is to return a value or a tuple instead, and to reserve & for the places the standard library already uses it (sort, preg_match, array_push).
Variadics That Know How Many There Are
A C variadic function cannot count its arguments or check their types — printf only works because the format string tells it, and lying about that is undefined behavior. A PHP variadic parameter is an ordinary array the engine fills in.
#include <stdio.h> #include <stdarg.h> static int sum_of(int count, ...) { va_list arguments; va_start(arguments, count); int total = 0; for (int index = 0; index < count; index++) { total += va_arg(arguments, int); } va_end(arguments); return total; } /* Optional arguments need a second function. */ static void log_message(const char *text, const char *level) { printf("[%s] %s\n", level, text); } static void log_simple(const char *text) { log_message(text, "info"); } int main(void) { printf("%d %d\n", sum_of(3, 1, 2, 3), sum_of(0)); log_simple("started"); log_message("careful", "warn"); return 0; }
<?php function sumOf(int ...$values): int { return array_sum($values); } function logMessage(string $text, string $level = "info"): void { echo "[$level] $text\n"; } echo sumOf(1, 2, 3), " ", sumOf(), "\n"; logMessage("started"); logMessage("careful", "warn"); logMessage(level: "debug", text: "named arguments work too"); $pieces = [4, 5, 6]; echo sumOf(...$pieces), "\n"; // spread, the reverse of collecting
The leading count parameter is gone because the array knows its length, and int ...$values type-checks every element — sumOf(1, "two") throws rather than reading a pointer as an integer. Named arguments are the other half of the story: they let a caller skip past a default without repeating it, which is what the three separate C wrapper functions were for.
A Closure Carries Its Context
Every C callback interface needs a void * beside the function pointer, because the pointer remembers nothing. A PHP closure captures what it needs — though unlike most languages, it captures only what you list.
#include <stdio.h> static void for_each(const int *values, size_t count, void (*action)(int value, void *context), void *context) { for (size_t index = 0; index < count; index++) { action(values[index], context); } } static void add_to_total(int value, void *context) { *(int *)context += value; } int main(void) { int numbers[] = { 1, 2, 3, 4 }; int total = 0; for_each(numbers, 4, add_to_total, &total); printf("total %d\n", total); return 0; }
<?php $numbers = [1, 2, 3, 4]; $total = 0; array_walk($numbers, function (int $value) use (&$total): void { $total += $value; // captured BY REFERENCE, hence the & }); echo "total $total\n"; $factor = 3; $scale = fn(int $value): int => $value * $factor; // arrow fn captures automatically echo implode(" ", array_map($scale, $numbers)), "\n";
PHP is unusual here: a function () use ($x) closure captures only the names in the use list, and by value unless you write &. The shorter fn() => arrow form captures everything it references, automatically and by value, which is why it is now the common spelling — and why it is limited to a single expression.
A Function Named by a String
A C function pointer is an address fixed at link time. PHP looks functions up by name in a table while running, so a callable can be a string, a closure, an object with __invoke, or a method reference — and can be chosen from data.
#include <stdio.h> static int doubled(int value) { return value * 2; } static int squared(int value) { return value * value; } int main(void) { int (*operations[2])(int) = { doubled, squared }; const char *names[2] = { "doubled", "squared" }; for (int index = 0; index < 2; index++) { printf("%s(7) = %d\n", names[index], operations[index](7)); } return 0; }
<?php function doubled(int $value): int { return $value * 2; } function squared(int $value): int { return $value * $value; } foreach (["doubled", "squared"] as $name) { echo "$name(7) = ", $name(7), "\n"; // called by name } $operation = strtoupper(...); // first-class callable syntax echo $operation("shout"), "\n"; echo "callable? ", var_export(is_callable("doubled"), true), "\n";
The strtoupper(...) spelling — a call with a literal three dots — makes a closure from an existing function without naming it in a string, which means the name is checked when the file compiles instead of when the call happens. That is the modern replacement for "strtoupper" and [$object, "method"], both of which still work and both of which fail only at the moment of the call.
Truthiness, Comparison, and match
What Counts as False
C has one rule: zero is false. PHP has a list, and the entries that are not on it are what catch a C programmer out — particularly the string "0", which is falsy, and the string "0.0", which is not.
#include <stdio.h> int main(void) { printf("0 -> %s\n", 0 ? "true" : "false"); printf("1 -> %s\n", 1 ? "true" : "false"); printf("-1 -> %s\n", -1 ? "true" : "false"); const char *empty = ""; printf("\"\" -> %s\n", empty ? "true" : "false"); return 0; }
<?php $cases = [ "0" => 0, "1" => 1, "-1" => -1, '""' => "", '"0"' => "0", '"0.0"' => "0.0", '" "' => " ", "[]" => [], "[0]" => [0], "null" => null, "0.0" => 0.0, ]; foreach ($cases as $label => $value) { printf("%-6s -> %s\n", $label, $value ? "true" : "false"); }
The falsy list is: false, 0, 0.0, "", "0", the empty array, and null. Every other value is truthy, including the string "0.0" and the array [0]. Note that the C column’s last line prints true: a char * to an empty string is a non-null pointer. Both languages have a trap here; they are just different traps.
== Converts, === Does Not
C converts operands too, but only among numeric types and never between a number and a string. PHP’s == will convert across types, which is why the language effectively has two equality operators and one of them is the one to use.
#include <stdio.h> #include <string.h> int main(void) { int number = 1; double also = 1.0; printf("1 == 1.0 %d\n", number == also); /* number == "1" does not compile: comparison between pointer and integer. Cross-type equality is simply not available. */ printf("strcmp equal %d\n", strcmp("1", "1") == 0); return 0; }
<?php $show = fn(string $label, bool $result): string => sprintf("%-16s %s\n", $label, var_export($result, true)); echo $show('1 == "1"', 1 == "1"); echo $show('1 === "1"', 1 === "1"); echo $show('"abc" == 0', "abc" == 0); // false since PHP 8 — was TRUE before echo $show('null == false', null == false); echo $show('"1e2" == "100"', "1e2" == "100"); echo $show('"1e2" === "100"', "1e2" === "100");
PHP 8 fixed the worst case: "abc" == 0 used to be true, because the string was converted to a number and non-numeric strings became zero. Now the number is converted to a string instead when the other side is non-numeric. Two numeric-looking strings are still compared as numbers, which is how "1e2" == "100" comes out true — so use === unless you have a specific reason not to.
switch Still Falls Through; match Does Not
PHP kept C’s switch, fallthrough and all, so the missing break bug came along with it. It then added match, which is an expression, does not fall through, and compares with ===.
#include <stdio.h> static const char *describe(int code) { switch (code) { case 1: case 2: return "low"; case 3: return "medium"; default: return "high"; } } int main(void) { printf("%s %s %s\n", describe(1), describe(3), describe(9)); /* The classic bug: no break, so it falls into the next case. */ int total = 0; switch (2) { case 2: total += 1; /* falls through */ case 3: total += 10; /* falls through */ default: total += 100; } printf("fell through to %d\n", total); return 0; }
<?php function describe(int $code): string { return match (true) { $code === 1, $code === 2 => "low", $code === 3 => "medium", default => "high", }; } echo describe(1), " ", describe(3), " ", describe(9), "\n"; // switch still falls through, exactly as in C: $total = 0; switch (2) { case 2: $total += 1; case 3: $total += 10; default: $total += 100; } echo "fell through to $total\n"; // match does not, and is exhaustive: try { echo match (99) { 1 => "one", 2 => "two" }; } catch (\UnhandledMatchError $error) { echo "match has no arm for 99, so it throws\n"; }
Both columns print 111 for the fallthrough, because the construct is the same one. match is the improvement: it is an expression so it can be returned or assigned directly, it uses === so "1" does not match 1, and an unmatched value throws rather than silently doing nothing — which is the check C’s -Wswitch only gives you for enums.
foreach Instead of an Index
The counted for loop still exists and still works. foreach is the one you will write, because it gives you the key and the value without an index variable to get wrong.
#include <stdio.h> int main(void) { int numbers[] = { 10, 20, 30 }; size_t count = sizeof numbers / sizeof numbers[0]; for (size_t index = 0; index < count; index++) { printf("%zu => %d\n", index, numbers[index]); } return 0; }
<?php $numbers = [10, 20, 30]; foreach ($numbers as $index => $value) { echo "$index => $value\n"; } // Modifying in place needs & — and the well-known trap that follows it: foreach ($numbers as &$value) { $value *= 2; } unset($value); // ALWAYS unset it afterwards echo implode(" ", $numbers), "\n";
That unset($value) is not superstition. After a by-reference foreach, $value is still a reference to the last element, so a second foreach using the same variable name overwrites that element on every iteration — a bug that produces a plausible-looking array with its last two entries wrong. It is the closest thing PHP has to a dangling pointer, and unsetting the variable is the fix.
A Struct With Functions Is a Class
The Struct-Plus-Functions Pattern, With Syntax
The C pattern is a struct and a family of functions taking a pointer to it as their first argument. A class folds that first argument into the syntax and calls it $this.
#include <stdio.h> struct Rectangle { double width; double height; }; static double rectangle_area(const struct Rectangle *self) { return self->width * self->height; } static void rectangle_scale(struct Rectangle *self, double factor) { self->width *= factor; self->height *= factor; } int main(void) { struct Rectangle shape = { 3.0, 4.0 }; printf("area %.1f\n", rectangle_area(&shape)); rectangle_scale(&shape, 2.0); printf("scaled area %.1f\n", rectangle_area(&shape)); return 0; }
<?php class Rectangle { public function __construct( private float $width, private float $height, ) {} public function area(): float { return $this->width * $this->height; } public function scale(float $factor): void { $this->width *= $factor; $this->height *= $factor; } } $shape = new Rectangle(3.0, 4.0); printf("area %.1f\n", $shape->area()); $shape->scale(2.0); printf("scaled area %.1f\n", $shape->area());
The -> is the same arrow you use on a struct pointer, which is not a coincidence — PHP borrowed it. Constructor property promotion, the private float $width in the parameter list, declares the property and assigns it in one place; before PHP 8 that took three lines per field. Note that objects, unlike arrays, are handles: assigning $other = $shape gives you a second name for one rectangle.
Interfaces Instead of a Table of Function Pointers
Polymorphism in C is a struct of function pointers each implementation fills in, plus the discipline of pairing the right table with the right data. An interface is that table, built and checked by the language.
#include <stdio.h> struct ShapeOperations { double (*area)(const void *self); }; struct Square { double side; }; struct Circle { double radius; }; static double square_area(const void *self) { double side = ((const struct Square *)self)->side; return side * side; } static double circle_area(const void *self) { double radius = ((const struct Circle *)self)->radius; return 3.14159265358979 * radius * radius; } int main(void) { struct ShapeOperations squareOps = { square_area }; struct ShapeOperations circleOps = { circle_area }; struct Square square = { 3.0 }; struct Circle circle = { 1.0 }; printf("%.2f\n", squareOps.area(&square)); printf("%.2f\n", circleOps.area(&circle)); return 0; }
<?php interface Shape { public function area(): float; } class Square implements Shape { public function __construct(private float $side) {} public function area(): float { return $this->side ** 2; } } class Circle implements Shape { public function __construct(private float $radius) {} public function area(): float { return M_PI * $this->radius ** 2; } } foreach ([new Square(3.0), new Circle(1.0)] as $shape) { printf("%.2f\n", $shape->area()); }
A class that says implements Shape and omits area fails to compile that file, which is the check the C column has no way to make — nothing stops you leaving a function pointer null. The void * casts are gone too, and with them the ability to pair the circle’s table with the square’s data. Both shapes can now sit in one array, which the C version cannot manage without a common header struct.
readonly and Backed Enums
C’s const on a struct member means "not assignable", checked at compile time. PHP’s readonly means "assignable exactly once, in the constructor", checked at run time — and its enum is a real type rather than an integer in disguise.
#include <stdio.h> enum Status { STATUS_DRAFT = 0, STATUS_PUBLISHED = 1 }; struct Article { const char *title; /* the pointer is not const */ enum Status status; }; int main(void) { struct Article article = { "Hello", STATUS_DRAFT }; printf("%s is %d\n", article.title, article.status); /* Nothing prevents this — the enum is an int. */ article.status = 99; printf("status is now %d\n", article.status); return 0; }
<?php enum Status: int { case Draft = 0; case Published = 1; public function label(): string { return ucfirst(strtolower($this->name)); } } final class Article { public function __construct( public readonly string $title, public readonly Status $status, ) {} } $article = new Article("Hello", Status::Draft); echo $article->title, " is ", $article->status->label(), "\n"; try { $article->title = "Changed"; } catch (Error $error) { echo "readonly: ", $error->getMessage(), "\n"; } echo "from value: ", Status::from(1)->name, "\n";
The C column’s last two lines are the point: article.status = 99 compiles and runs, because an enum is an int and any int fits. Status here is a type — a parameter declared Status accepts nothing else, Status::from(99) throws, and the enum can carry methods. That combination is what C programmers most often reach for a lookup table to fake.
Exceptions Instead of Return Codes
An Exception Cannot Be Ignored
A returned status can be ignored, and usually nothing warns. A thrown exception unwinds until something catches it, so the failure path runs whether or not the caller wrote one.
#include <stdio.h> #include <stdlib.h> #include <errno.h> int main(void) { const char *input = "not a number"; char *end = NULL; errno = 0; long parsed = strtol(input, &end, 10); if (end == input || *end != '\0') { printf("parse failed, and only the check found out\n"); } else { printf("parsed %ld\n", parsed); } strtol("also bad", NULL, 10); /* result ignored, silently */ printf("ignored, program continues\n"); return 0; }
<?php declare(strict_types=1); function parseCount(string $text): int { if (!ctype_digit($text)) { throw new InvalidArgumentException("\"$text\" is not a number"); } return (int) $text; } try { echo parseCount("42"), "\n"; echo parseCount("not a number"), "\n"; } catch (InvalidArgumentException $error) { echo get_class($error), ": ", $error->getMessage(), "\n"; } // The quiet alternative the language itself offers: echo "filter_var: ", var_export(filter_var("bad", FILTER_VALIDATE_INT), true), "\n";
PHP’s own conversions mostly do not throw: (int) "bad" is 0, and a bad numeric string in arithmetic raises a warning rather than an error. So arriving from C you get exceptions where your code raises them and quiet wrong values everywhere else, which is why filter_var and explicit validation at the boundary matter more here than in a language that checks types for you.
finally Instead of goto cleanup
The goto cleanup ladder handles the failures this function noticed. finally also handles the one thrown three frames down, which is the case the ladder cannot reach.
#include <stdio.h> #include <stdlib.h> int main(void) { char *first = NULL, *second = NULL; int result = 1; first = malloc(16); if (first == NULL) goto cleanup; second = malloc(16); if (second == NULL) goto cleanup; printf("both acquired\n"); result = 0; cleanup: free(second); free(first); printf("cleanup ran, result %d\n", result); return 0; }
<?php function work(): string { try { echo "both acquired\n"; throw new RuntimeException("something failed deeper down"); } catch (RuntimeException $error) { echo "caught: ", $error->getMessage(), "\n"; return "handled"; } finally { echo "cleanup ran\n"; } } $result = work(); echo "result: $result\n";
The output proves the ordering: finally runs after the return value has been computed but before the function actually returns. That makes it the right home for closing a handle or releasing a lock, and the wrong home for a return of its own — a return inside finally silently replaces the one on its way out, including replacing an in-flight exception.
Engine Errors Are Catchable Too
Things that used to be fatal — calling a method on null, a type mismatch, dividing by zero — are now thrown objects. They derive from Error rather than Exception, which is the language’s way of saying "this is a bug, not a condition."
#include <stdio.h> #include <signal.h> int main(void) { /* Each of these is undefined behavior in C, and the program's only options are to check first or to die: there is nothing to catch. */ int denominator = 0; if (denominator == 0) { printf("division by zero: must check first\n"); } int *nothing = NULL; if (nothing == NULL) { printf("null dereference: must check first\n"); } return 0; }
<?php declare(strict_types=1); try { echo intdiv(1, 0); } catch (DivisionByZeroError $error) { echo "DivisionByZeroError\n"; } try { $nothing = null; $nothing->method(); } catch (Error $error) { echo get_class($error), ": ", $error->getMessage(), "\n"; } // Throwable is the common ancestor of Error and Exception: try { throw new TypeError("declared types are enforced"); } catch (Throwable $error) { echo "caught as Throwable: ", get_class($error), "\n"; }
Catch Exception for conditions you expect and Error only at the top of the program, to log a bug before exiting — swallowing a TypeError in the middle of a request usually just moves the failure somewhere less informative. Throwable catches both and is what a framework’s outermost handler uses.
Reference Counting Instead of free
Reference Counting Instead of free
Every value carries a count of how many things point at it. When the count reaches zero the value is destroyed immediately — which is more predictable than a tracing collector and much closer to the timing you are used to.
#include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { char *buffer = malloc(16); if (buffer == NULL) return 1; strcpy(buffer, "alive"); printf("%s\n", buffer); free(buffer); /* exactly one free per malloc */ printf("freed at a moment we chose\n"); return 0; }
<?php class Noisy { public function __construct(private string $name) {} public function __destruct() { echo "destroyed: {$this->name}\n"; } } $first = new Noisy("first"); $second = $first; // refcount is now 2 unset($first); // still 1 — nothing happens echo "after unset(\$first)\n"; unset($second); // 0 — the destructor runs right here echo "after unset(\$second)\n";
The destructor runs at a moment you can predict, which is why __destruct is a workable place to close a file handle in a way that finalize in a garbage-collected language is not. What reference counting cannot free on its own is a cycle — two objects pointing at each other keep each other alive — so PHP also runs a cycle collector periodically, and gc_collect_cycles() forces it.
The Copy That Does Not Happen Until You Write
Arrays and strings have value semantics, which sounds expensive. It is not: the engine shares the buffer between the two variables and clones it only when one of them is modified — so reading a large array through a copy costs nothing.
#include <stdio.h> #include <stdlib.h> #include <string.h> /* Value semantics in C mean an actual copy, right now. */ static int *duplicate(const int *source, size_t count) { int *copy = malloc(count * sizeof *copy); if (copy == NULL) return NULL; memcpy(copy, source, count * sizeof *copy); return copy; } int main(void) { int numbers[] = { 1, 2, 3 }; int *copy = duplicate(numbers, 3); copy[0] = 99; printf("original %d, copy %d\n", numbers[0], copy[0]); free(copy); return 0; }
<?php $numbers = range(1, 100000); $copy = $numbers; // no copying yet — the buffer is shared echo "sharing: ", number_format(memory_get_usage()), " bytes\n"; $copy[0] = 99; // NOW it is cloned echo "after write: ", number_format(memory_get_usage()), " bytes\n"; echo "original[0] ", $numbers[0], ", copy[0] ", $copy[0], "\n";
The reported memory jumps at the write, not at the assignment, which is the whole mechanism in one measurement. The practical consequence for a C programmer: passing a big array to a function that only reads it is free, and there is no reason to reach for & to avoid a copy. Add & only when the function is meant to modify the caller’s array.
Real Bytes: pack, unpack and Endianness
pack and unpack Are Your struct on the Wire
Writing a C struct straight to a file is not a format — padding, alignment and byte order all leak into it. pack builds a byte string from a format specification, field by field, with the byte order named explicitly.
#include <stdio.h> #include <stdint.h> struct Record { uint16_t kind; uint32_t length; }; int main(void) { struct Record record = { 7, 1024 }; /* Portable big-endian encoding, written by hand. */ unsigned char wire[6]; wire[0] = (unsigned char)(record.kind >> 8); wire[1] = (unsigned char)record.kind; wire[2] = (unsigned char)(record.length >> 24); wire[3] = (unsigned char)(record.length >> 16); wire[4] = (unsigned char)(record.length >> 8); wire[5] = (unsigned char)record.length; printf("packed 6 bytes:"); for (size_t index = 0; index < 6; index++) { printf(" %02x", wire[index]); } printf("\n"); printf("sizeof(struct Record) is %zu, with padding\n", sizeof(struct Record)); return 0; }
<?php $wire = pack("nN", 7, 1024); // n = uint16 big-endian, N = uint32 big-endian echo "packed ", strlen($wire), " bytes: ", implode(" ", str_split(bin2hex($wire), 2)), "\n"; ["kind" => $kind, "length" => $length] = unpack("nkind/Nlength", $wire); echo "kind $kind, length $length\n";
The two columns produce the same six bytes. The C column also prints sizeof(struct Record) — 8, not 6, because the compiler padded length onto a four-byte boundary — which is exactly why fwrite(&record, sizeof record, 1, file) is not a wire format. The format letters are worth memorizing: n/N are big-endian 16 and 32, v/V little-endian, J/P the 64-bit pair, and C a single byte.
Bytes as Numbers: ord, chr, bin2hex
A C char is already a small integer, so converting between them is a cast. PHP keeps bytes inside strings, so the conversion has two named functions — and a few more for turning a whole buffer into something printable.
#include <stdio.h> #include <string.h> int main(void) { const char *text = "AB\xff"; size_t length = strlen(text); for (size_t index = 0; index < length; index++) { printf("%02x ", (unsigned char)text[index]); } printf("\n"); printf("char 65 is %c\n", 65); printf("'A' is %d\n", 'A'); return 0; }
<?php $text = "AB\xff"; echo implode(" ", str_split(bin2hex($text), 2)), "\n"; echo "char 65 is ", chr(65), "\n"; echo "'A' is ", ord("A"), "\n"; // The round trip, and a byte-by-byte walk: $rebuilt = ""; foreach (str_split($text) as $byte) { $rebuilt .= chr(ord($byte)); } echo "round trip equal: ", var_export($rebuilt === $text, true), "\n"; echo "base64: ", base64_encode($text), "\n";
ord takes the first byte of the string and returns 0–255, so it is (unsigned char) rather than (char) — there is no sign question to worry about. str_split with no second argument gives one-byte pieces, which is the byte-array view of a string; unpack("C*", $text) gives the same thing as integers, and is faster for a large buffer.
Endianness Is Named, Not Assumed
C leaves the byte order of a multi-byte value to the platform, which is why network code is full of htonl. PHP’s pack format letters name the order at every call, so there is no default to be wrong about — but you still have to know which one the format wants.
#include <stdio.h> #include <stdint.h> #include <string.h> int main(void) { uint32_t value = 0x01020304u; unsigned char big[4] = { (unsigned char)(value >> 24), (unsigned char)(value >> 16), (unsigned char)(value >> 8), (unsigned char)value, }; printf("big: %02x %02x %02x %02x\n", big[0], big[1], big[2], big[3]); unsigned char little[4] = { (unsigned char)value, (unsigned char)(value >> 8), (unsigned char)(value >> 16), (unsigned char)(value >> 24), }; printf("little: %02x %02x %02x %02x\n", little[0], little[1], little[2], little[3]); unsigned char native[4]; memcpy(native, &value, sizeof native); printf("native: %02x %02x %02x %02x\n", native[0], native[1], native[2], native[3]); return 0; }
<?php $value = 0x01020304; foreach (["N" => "big", "V" => "little", "L" => "native"] as $format => $name) { $bytes = pack($format, $value); printf("%-7s %s\n", $name . ":", implode(" ", str_split(bin2hex($bytes), 2))); } echo "this machine is ", (pack("L", 1) === pack("V", 1) ? "little" : "big"), "-endian\n";
The last line is the PHP spelling of the classic C endianness probe, and it works the same way: encode 1 in native order and compare it with 1 encoded little-endian. Use N and V for anything that leaves the process; L and its siblings are for talking to native code on the same machine, which is what the FFI section is about.
File I/O With the Names You Know
fopen, fgets, fread, fclose — The Same Names
The stdio family came across nearly unchanged, mode strings and all. What changed is that the handle is an object rather than a FILE *, and that the whole-file shortcuts most code actually uses have no C counterpart.
#include <stdio.h> int main(void) { const char *path = "/tmp/c-php-io.txt"; FILE *handle = fopen(path, "w"); if (handle == NULL) return 1; fprintf(handle, "first line\n"); fprintf(handle, "second line\n"); fclose(handle); handle = fopen(path, "r"); if (handle == NULL) return 1; char line[64]; while (fgets(line, sizeof line, handle) != NULL) { printf("read: %s", line); } fclose(handle); remove(path); return 0; }
<?php $path = sys_get_temp_dir() . "/c-php-io.txt"; $handle = fopen($path, "w"); fwrite($handle, "first line\n"); fwrite($handle, "second line\n"); fclose($handle); $handle = fopen($path, "r"); while (($line = fgets($handle)) !== false) { echo "read: $line"; } fclose($handle); // The shortcut that most code actually uses: echo "whole file is ", strlen(file_get_contents($path)), " bytes\n"; unlink($path);
fgets needs no buffer and no size, because the string it returns is allocated for you — and it returns false at end of file, which is why the loop compares with !== false rather than testing truthiness (a line containing just "0" would end the loop otherwise). file_get_contents reads the whole file into one binary-safe string, which is the operation C makes you write fseek/ftell/malloc/fread for.
The C Lineage in the Standard Library
The math.h Names Are All Here
Every function you would #include <math.h> for exists with the same name and the same behavior, because they are thin wrappers around the same C library.
#include <stdio.h> #include <math.h> int main(void) { printf("sqrt %.4f\n", sqrt(2.0)); printf("pow %.1f\n", pow(2.0, 10.0)); printf("floor %.0f\n", floor(-2.5)); printf("ceil %.0f\n", ceil(-2.5)); printf("fmod %.1f\n", fmod(7.5, 2.0)); /* M_PI is a POSIX extension, not ISO C: with -std=c17 it is not declared, so pi has to be spelled out. */ printf("pi %.5f\n", 3.14159265358979323846); printf("inf %d\n", isinf(1.0 / 0.0)); return 0; }
<?php printf("sqrt %.4f\n", sqrt(2.0)); printf("pow %.1f\n", pow(2.0, 10.0)); printf("floor %.0f\n", floor(-2.5)); printf("ceil %.0f\n", ceil(-2.5)); printf("fmod %.1f\n", fmod(7.5, 2.0)); printf("pi %.5f\n", M_PI); printf("inf %s\n", var_export(is_infinite(INF), true));
The two columns print the same seven lines, but the pi row needed care: M_PI is a POSIX extension rather than ISO C, so under -std=c17 the C column does not compile with it and the constant has to be written out — while PHP defines M_PI unconditionally. ** is available as an operator for pow, and PHP_FLOAT_EPSILON is DBL_EPSILON — the comparison advice is identical, because the representation is identical. One addition with no C counterpart: random_int is a cryptographically secure integer generator, which saves reaching for /dev/urandom by hand.
SplFixedArray: A Real Array, When You Want One
A PHP array is a hash table, so a list of a million integers costs far more than int[1000000]. SplFixedArray is the compact alternative: a fixed size, integer indices only, and a bounds check that throws.
#include <stdio.h> #include <stdlib.h> int main(void) { size_t count = 5; int *numbers = calloc(count, sizeof *numbers); if (numbers == NULL) return 1; for (size_t index = 0; index < count; index++) { numbers[index] = (int)index * 10; } printf("%d %d\n", numbers[0], numbers[4]); printf("bytes %zu\n", count * sizeof *numbers); free(numbers); return 0; }
<?php $numbers = new SplFixedArray(5); foreach ($numbers as $index => $unused) { $numbers[$index] = $index * 10; } echo $numbers[0], " ", $numbers[4], "\n"; echo "size ", $numbers->getSize(), "\n"; try { echo $numbers[9]; } catch (RuntimeException $error) { echo "index 9: ", $error->getMessage(), "\n"; }
The bounds check is the part a plain array does not give you: $array[9] on an ordinary PHP array is a warning and null, while this throws. The memory saving is roughly four to five times for a large list of integers, which is why it shows up in numeric code and almost nowhere else — for ordinary work the hash-table array is what everything else in the library expects.
Regular Expressions Are in the Language
C has POSIX regcomp/regexec if you are on a Unix, and nothing portable otherwise. PHP ships PCRE — the same engine Perl made famous — and the pattern goes in an ordinary string with delimiters.
#include <stdio.h> #include <regex.h> int main(void) { regex_t pattern; if (regcomp(&pattern, "([0-9]+)-([0-9]+)", REG_EXTENDED) != 0) { return 1; } regmatch_t matches[3]; const char *text = "order 42-7 shipped"; if (regexec(&pattern, text, 3, matches, 0) == 0) { printf("whole: %.*s\n", (int)(matches[0].rm_eo - matches[0].rm_so), text + matches[0].rm_so); printf("first: %.*s\n", (int)(matches[1].rm_eo - matches[1].rm_so), text + matches[1].rm_so); } regfree(&pattern); return 0; }
<?php $text = "order 42-7 shipped"; if (preg_match('/(\d+)-(\d+)/', $text, $matches)) { echo "whole: ", $matches[0], "\n"; echo "first: ", $matches[1], "\n"; } echo preg_replace('/\d+/', "#", $text), "\n"; echo implode("|", preg_split('/\s+/', $text)), "\n";
The / characters around the pattern are delimiters, not part of it, and a trailing letter after the closing one is a flag — i for case-insensitive, u for UTF-8 mode. The matches arrive as an ordinary array with group 0 being the whole match, which is the same numbering as regmatch_t, with the offset arithmetic done for you. Named groups ((?<year>\d{4})) show up as string keys in the same array.
Where They Still Meet: FFI
FFI: Calling a Shared Library Directly
This is the shortest path from PHP back to your existing C. You hand FFI::cdef a fragment of C declarations and the name of a shared library, and the functions become methods on the returned object.
#include <stdio.h> #include <string.h> /* Compiled into a shared library, this is what the PHP declaration on the right binds to. */ size_t string_length(const char *text) { return strlen(text); } int main(void) { printf("%zu\n", string_length("hello")); return 0; }
<?php // Requires the FFI extension, which is off by default and which the // runners on this page do not provide. $libc = FFI::cdef(<<<'HEADER' size_t strlen(const char *text); int abs(int value); HEADER, "libc.so.6"); echo $libc->strlen("hello"), "\n"; echo $libc->abs(-5), "\n"; // Allocating native memory, with the free you are back to writing: $buffer = FFI::new("char[16]"); FFI::memcpy($buffer, "hello", 5); echo FFI::string($buffer, 5), "\n";
The declarations are parsed by PHP’s own C parser, so no header file and no compilation step are involved — but nothing checks them against the library, and a wrong parameter width corrupts the stack rather than raising an error. FFI is disabled by default in production for exactly that reason. The genuinely supported path for shipping C to PHP is still a Zend extension, which is a C file against PHP’s own API, compiled with phpize.
The Engine Is a C Program, and It Shows
Every PHP value is a zval — a tagged union of a type and a value — and every function you have called on this page is a C function taking an array of them. Knowing the shape explains several things the language does.
#include <stdio.h> #include <stdint.h> /* A sketch of the engine's value representation: a tag and a union, which is the same design you would reach for. */ struct Value { enum { TYPE_NULL, TYPE_LONG, TYPE_DOUBLE, TYPE_STRING } type; union { int64_t as_long; double as_double; struct { size_t length; char *bytes; } as_string; } value; }; int main(void) { struct Value number = { TYPE_LONG, { .as_long = 42 } }; printf("tag %d, value %lld\n", (int)number.type, (long long)number.value.as_long); printf("sizeof a tagged value: %zu bytes\n", sizeof(struct Value)); return 0; }
<?php $number = 42; $text = "hello"; echo gettype($number), " ", gettype($text), "\n"; echo "PHP version ", PHP_VERSION, "\n"; echo "int width ", PHP_INT_SIZE, " bytes\n"; echo "engine Zend Engine ", zend_version(), "\n";
A string in that union carries its length, which is why strlen is O(1) and why zero bytes are ordinary data. Reference counting lives in the same header, which is why unset can free a value immediately. And an array is a hash table of these tagged values, which is why one array can hold mixed types and why it costs several times what a C array of the same length would.