PONYλM2Modula-2

Java.CodeCompared.To/PHP

An interactive executable cheatsheet comparing Java and PHP

Java 25 PHP 8.3.12 (Wandbox), 8.3.11 (Judge0) or 8.5.8 (in-browser)
The Request Lifecycle
Static state does not survive the request
This is the row the whole page hangs on, and it is the one thing a single runnable example cannot show: both columns print visit 1 then visit 2, because both increments happen inside one process. What differs is what happens next.
class Counter { static int visits = 0; } class Main { public static void main(String[] args) { Counter.visits += 1; System.out.println("visit " + Counter.visits); Counter.visits += 1; System.out.println("visit " + Counter.visits); } }
<?php declare(strict_types=1); final class Counter { public static int $visits = 0; } Counter::$visits += 1; echo "visit ", Counter::$visits, "\n"; Counter::$visits += 1; echo "visit ", Counter::$visits, "\n"; // still the SAME request
Your JVM keeps that static field for weeks, which is why a leak matters, why a singleton cache works, and why one bad request can poison every later one. A PHP request starts a fresh interpreter, runs your file, sends the output and throws everything away — including every static field, so the next request sees visit 1 again. Anything that must survive goes to a session, a cache like Redis, or the database. Almost everything else on this page follows from that sentence.
No warmup, no pools, no JIT
The performance model is the other half of the lifecycle, and the mapping is not "PHP is slow" — it is that the two runtimes optimise different things.
class Main { // A Java service: one process, warmed by the JIT, holding a connection // pool, a thread pool and every cache you built at startup. public static void main(String[] args) { long start = System.nanoTime(); long total = 0; for (int index = 0; index < 1_000_000; index++) total += index; System.out.println(total + " (the JIT compiled this loop while it ran)"); } }
<?php declare(strict_types=1); // A PHP request: a fresh interpreter every time. Opcache keeps COMPILED // BYTECODE in memory between requests; it keeps none of your objects. $total = 0; for ($index = 0; $index < 1_000_000; $index++) { $total += $index; } echo $total, " (interpreted, every request)\n";
There is no warmup to wait for and no JIT tiering to reason about (PHP 8 has an optional JIT, and it helps compute-bound code rather than typical web work). Opcache is the piece that makes this viable: it caches the compiled bytecode of your files between requests, so parsing is paid once rather than per request — and it is a deployment concern, since a stale opcache serves old code. What genuinely disappears is the connection pool: each request opens its own database connection unless you put a proxy in front, which is why persistent connections and PgBouncer-style poolers matter more here than on the JVM.
The request is ambient, not injected
A servlet or a Spring controller receives the request as an argument. In PHP the request is the environment: already there, in superglobals, before your first line runs.
import java.util.Map; class Main { static String handle(Map<String, String> query) { String name = query.getOrDefault("name", "stranger"); return "hello " + name; } public static void main(String[] args) { System.out.println(handle(Map.of("name", "Ada"))); } }
<?php declare(strict_types=1); // The web server fills $_GET in; this line stands in for it so the example runs. $_GET = ['name' => 'Ada']; $name = $_GET['name'] ?? 'stranger'; echo "hello $name\n";
$_GET, $_POST, $_SERVER, $_COOKIE, $_FILES and $_SESSION are visible in every scope without being passed or imported, because there is only ever one request in the process to confuse them with. That is also why the pattern is safe here and would be a catastrophe on the JVM. Every value in them is attacker-controlled text with no schema, so validate at the boundary. Symfony and Laravel wrap them in a PSR-7 Request object precisely so code can be tested and typed.
Output & Running
Hello, World — and the ceremony that is gone
A PHP file starts in HTML mode and becomes code only after <?php — the tell that the language began as a template system. Statements sit at file scope; there is no class, no main, and no file-name rule.
class Main { public static void main(String[] args) { System.out.println("Hello, World!"); } }
<?php echo "Hello, World!\n";
echo writes exactly what you give it and adds no newline, so you write one. A file that is nothing but PHP omits the closing ?> by convention, because whitespace after it is sent to the browser and breaks a later header() call. There is no compile step and no artifact: the .php file you write is the file the server executes.
String interpolation, which Java withdrew
PHP interpolates inside ordinary double-quoted strings. Single quotes never interpolate, which is how you say "leave this alone" — and it is a real performance and clarity habit, not a stylistic one.
class Main { public static void main(String[] args) { String name = "Ada"; int age = 36; System.out.println(name + " is " + age); System.out.printf("%s is %d%n", name, age); } }
<?php $name = "Ada"; $age = 36; echo "$name is $age\n"; printf("%s is %d\n", $name, $age);
A bare $name works; anything more complex needs braces, as in "{$user['name']}" or "{$order->total}". Java previewed string templates in 21 and 22 and then withdrew them, so this is one of the rare places PHP has the more modern syntax. Concatenation is . rather than +, and + on two strings is a TypeError in PHP 8. printf is C's and behaves as Java's does, minus %n.
Multi-line strings
Java text blocks and PHP heredocs are the same feature, including the rule that strips the common indentation.
class Main { public static void main(String[] args) { String user = "Ada"; String message = """ Dear %s, Your order has shipped. """.formatted(user); System.out.print(message); } }
<?php $user = "Ada"; $message = <<<TEXT Dear $user, Your order has shipped. TEXT; echo $message, "\n";
PHP's margin is set by the indentation of the closing marker (since 7.3), where Java uses the least-indented line or the closing delimiter, whichever is further left. The difference that matters is interpolation: a heredoc interpolates, so formatted(...) is unnecessary. Use <<<'TEXT' with quotes around the marker for the nowdoc form — no interpolation and no escapes, which is the closer analogue of a Java text block.
Types Checked at Run Time
Declared types, enforced on every call
PHP's type declarations are not Java's. Nothing is checked when the file is parsed; the check happens at the moment of the call, and it throws.
class Main { static int double_(int value) { return value * 2; } public static void main(String[] args) { System.out.println(double_(21)); // System.out.println(double_("21")); // uncomment: does not compile } }
<?php declare(strict_types=1); function doubleValue(int $value): int { return $value * 2; } echo doubleValue(21), "\n"; try { echo doubleValue("21"), "\n"; } catch (TypeError $error) { echo "TypeError: refused a numeric string\n"; }
That is a genuinely different trade, and it is worth appreciating rather than dismissing: a wrong argument fails at the boundary with a named error rather than three frames deeper. declare(strict_types=1) as the first statement of every file is the setting you want — without it PHP coerces, so "21" silently becomes 21 and a float is truncated. It is per-file rather than per-project, which surprises everyone once. Static analysers (PHPStan, Psalm) read the same declarations and give you the compile-time half.
No generics in the language
PHP has no generics at all, and the ecosystem's answer lives in comments that the engine ignores and the analysers enforce.
import java.util.List; class Main { static <T> T firstOrDefault(List<T> items, T fallback) { return items.isEmpty() ? fallback : items.get(0); } public static void main(String[] args) { System.out.println(firstOrDefault(List.of(1, 2), 0)); System.out.println(firstOrDefault(List.of(), "none")); } }
<?php declare(strict_types=1); /** * @template T * @param list<T> $items * @param T $fallback * @return T */ function firstOrDefault(array $items, mixed $fallback): mixed { return $items === [] ? $fallback : $items[0]; } echo firstOrDefault([1, 2], 0), "\n"; echo firstOrDefault([], "none"), "\n";
@template T is read by PHPStan and Psalm, which check the calls and infer the element type through your code — genuinely comparable to what javac does with <T>. The difference from Java's erasure is that here nothing exists at run time either: the engine sees mixed. Two habits to bring across: type collection parameters as list<User> or array<string, User> in a docblock (array alone says nothing), and run PHPStan at level 9 in CI so those comments are load-bearing rather than decorative.
One empty value, and ?-> for the rest
PHP has one null, a nullable type declaration (?string) that the engine enforces, and a nullsafe operator — so the Optional chain collapses to one line.
import java.util.Optional; record Address(String city) {} record Customer(Address address) {} class Main { public static void main(String[] args) { Customer customer = new Customer(new Address(null)); String city = Optional.ofNullable(customer) .map(Customer::address) .map(Address::city) .orElse("unknown"); System.out.println(city); } }
<?php declare(strict_types=1); final class Address { public function __construct(public readonly ?string $city) {} } final class Customer { public function __construct(public readonly ?Address $address) {} } $customer = new Customer(new Address(null)); echo $customer?->address?->city ?? "unknown", "\n";
?-> short-circuits the whole chain to null at the first empty, and ?? supplies the fallback exactly as orElse does. There is no Optional and no need for one at this scale. The type half is stronger than Java's too: a function declaring : string genuinely cannot return null — it throws on the way out — where a Java method returning String may hand you null with nothing to say so. isset() means "set and not null"; empty() is the trap, since it is also true for 0, "0", "" and [].
int and float, with no fixed width
PHP has a real int and a real float, and they are distinct types in a signature — but the arithmetic rules differ from Java's in two places worth knowing.
class Main { public static void main(String[] args) { System.out.println(7 / 2); System.out.println(7 / 2.0); System.out.println(Long.MAX_VALUE + 1); System.out.println(0.1 + 0.2); } }
<?php echo intdiv(7, 2), "\n"; echo 7 / 2, "\n"; echo var_export(PHP_INT_MAX + 1, true), "\n"; echo var_export(0.1 + 0.2, true), "\n";
First, / always promotes to float: 7 / 2 is 3.5, not 3, and integer division is intdiv. Second, there is no overflow: past PHP_INT_MAX an integer silently becomes a float and starts losing precision, where a Java long wraps to a negative number. Neither is checked, so both are quiet — but a float is at least approximately right. Note the printing: plain echo rounds to 14 significant digits and hides 0.1 + 0.2, while var_export and json_encode show the truth.
One array for List and Map
List, Map and tuple are one type
PHP has one container where Java has a whole collections framework. An array is an ordered map: keys are integers or strings, insertion order is preserved, and a list is simply the case where the keys are 0, 1, 2.
import java.util.List; import java.util.Map; class Main { public static void main(String[] args) { List<Integer> numbers = List.of(10, 20, 30); Map<String, Object> person = Map.of("name", "Ada", "age", 36); System.out.println(numbers.get(1) + " " + person.get("age")); System.out.println(numbers.size() + " " + person.size()); } }
<?php $numbers = [10, 20, 30]; $person = ['name' => 'Ada', 'age' => 36]; echo $numbers[1], " ", $person['age'], "\n"; echo count($numbers), " ", count($person), "\n";
So count() works on both and every array function takes both. What you give up is the type-level distinction — array as a declared type says nothing about keys or elements, which is why the docblock vocabulary from the generics row matters. array_is_list() (8.1) is the run-time check, and it has a visible consequence: json_encode emits […] for a list and {…} for anything else, so removing one element from the middle changes the shape of your API response.
An array is a value; an object is a reference
This is the difference that surprises Java developers more than anything else in the language. A PHP array is a value: assigning it, or passing it to a function, copies it.
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<Integer> first = new ArrayList<>(List.of(1, 2, 3)); List<Integer> second = first; // the SAME list second.add(4); System.out.println(first.size()); } }
<?php $first = [1, 2, 3]; $second = $first; // a COPY $second[] = 4; echo count($first), "\n";
So a function that appends to an array parameter changes nothing the caller can see, and the whole defensive-copying discipline the JVM requires — List.copyOf on the way in and out, Collections.unmodifiableList for a view — is simply unnecessary. The copy is lazy underneath (copy-on-write), so the cost is paid only on modification. Declare &$items to pass by reference when you genuinely want the Java behaviour. Objects go the other way and behave exactly as in Java: $b = $a gives two names for one instance, and clone makes a shallow copy.
Streams become array functions
The same three operations exist as free functions rather than as a pipeline, so there is no chaining and each step gets a name.
import java.util.List; class Main { public static void main(String[] args) { List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6); int total = numbers.stream() .filter(number -> number % 2 == 0) .map(number -> number * number) .reduce(0, Integer::sum); System.out.println(total); } }
<?php declare(strict_types=1); $numbers = [1, 2, 3, 4, 5, 6]; $evens = array_filter($numbers, fn(int $number): bool => $number % 2 === 0); $squares = array_map(fn(int $number): int => $number * $number, $evens); $total = array_reduce($squares, fn(int $running, int $number): int => $running + $number, 0); echo $total, "\n";
They are also eager: each call builds a whole new array, where a stream is lazy and fused. For a large source that matters, and the answer is a foreach or a generator. Watch the argument order, which is genuinely inconsistent — array_map(callback, array) puts the callback first while array_filter(array, callback) puts the array first. And array_filter preserves keys, so filtering a list can leave keys 1, 3, 5 and turn the next json_encode into an object; wrap it in array_values().
Sorting sorts in place
PHP sorts in place and returns a boolean, so the "sorted copy" idiom is a copy first — which is free, since assignment already copies.
import java.util.Comparator; import java.util.List; record Person(String name, int age) {} class Main { public static void main(String[] args) { List<Person> people = List.of(new Person("Ada", 36), new Person("Bob", 25)); people.stream() .sorted(Comparator.comparingInt(Person::age)) .forEach(person -> System.out.println(person.name() + " " + person.age())); } }
<?php declare(strict_types=1); final class Person { public function __construct(public readonly string $name, public readonly int $age) {} } $people = [new Person("Ada", 36), new Person("Bob", 25)]; usort($people, fn(Person $left, Person $right): int => $left->age <=> $right->age); foreach ($people as $person) { echo $person->name, " ", $person->age, "\n"; }
The comparator contract is identical: negative, zero or positive, and PHP's <=> spaceship operator produces exactly that for any two comparable values. The family is large and the names encode the axis: sort discards keys, asort keeps them, ksort sorts by key, and the u prefix takes your comparator. There is no Comparator.comparing(...).thenComparing(...) builder — a compound sort is one comparator with ?: chaining the spaceships.
foreach, with keys
One loop covers both cases, because there is one container type, and insertion order is always preserved — there is no HashMap-versus-LinkedHashMap decision to make.
import java.util.LinkedHashMap; import java.util.Map; class Main { public static void main(String[] args) { Map<String, Integer> ages = new LinkedHashMap<>(); ages.put("ada", 36); ages.put("grace", 45); for (Map.Entry<String, Integer> entry : ages.entrySet()) { System.out.println(entry.getKey() + " " + entry.getValue()); } } }
<?php $ages = ['ada' => 36, 'grace' => 45]; foreach ($ages as $name => $age) { echo $name, " ", $age, "\n"; }
Since arrays are values, foreach iterates a copy and modifying the array inside the loop is safe: there is no ConcurrentModificationException and no iterator to invalidate. Writing foreach ($items as &$item) takes a reference so you can modify elements in place, and it carries a famous trap — $item is still a reference to the last element after the loop, so a second foreach reusing that name corrupts the array. unset($item) after the loop, or avoid the ampersand.
Strings
Functions, not methods — with an argument order to learn
There are no string methods: every operation is a global function, so a chain of four becomes four nested calls read inside-out.
class Main { public static void main(String[] args) { String title = " Hello, World "; System.out.println(title.trim()); System.out.println(title.trim().toUpperCase()); System.out.println(title.replace("World", "PHP").trim()); System.out.println(title.contains("World")); } }
<?php $title = " Hello, World "; echo trim($title), "\n"; echo strtoupper(trim($title)), "\n"; echo trim(str_replace("World", "PHP", $title)), "\n"; echo var_export(str_contains($title, "World"), true), "\n";
The argument order is the part everyone complains about. str_replace($search, $replace, $subject) puts the subject last while strpos($haystack, $needle) puts it first; the inconsistency is historical and permanent. The modern additions are consistent and worth preferring: str_contains, str_starts_with and str_ends_with (PHP 8.0) take the haystack first and return real booleans, replacing the old strpos(...) !== false dance.
== compares text
Here PHP is straightforwardly easier, and the Java reflex to reach for .equals is worth un-learning for once.
class Main { public static void main(String[] args) { String first = "hello"; String second = new String("hel") + "lo"; System.out.println(first == second); // identity — false System.out.println(first.equals(second)); // contents — true } }
<?php $first = "hello"; $second = "hel" . "lo"; echo var_export($first == $second, true), "\n"; // contents — true echo var_export($first === $second, true), "\n"; // type AND contents — true
PHP strings are values, not objects, so there is no identity to accidentally compare: == compares text. === additionally requires the same type, which is what you want — "1" == 1 is still true in PHP 8 while "1" === 1 is false. The same applies to arrays: === compares contents (and key order), which is a real convenience after Arrays.equals and List.equals. For objects, PHP keeps the Java rule — == is same class with equal properties, === is the same instance.
Strings are bytes, not UTF-16
A PHP string is a byte array. Every function without an mb_ prefix counts and slices bytes, which gives answers a Java reader will not expect.
class Main { public static void main(String[] args) { String word = "naïve"; System.out.println(word.length()); System.out.println(word.toUpperCase()); System.out.println(word.substring(0, 3)); } }
<?php $word = "naïve"; echo strlen($word), " bytes vs ", mb_strlen($word), " characters\n"; echo strtoupper($word), " vs ", mb_strtoupper($word), "\n"; echo substr($word, 0, 3), " vs ", mb_substr($word, 0, 3), "\n";
A Java String is UTF-16, so length() counts code units — imperfect for astral characters but at least "naïve".length() is 5. PHP's strlen says 6, and substr($word, 0, 3) can cut a character in half and produce invalid UTF-8. The rule: use the mb_* family for anything user-facing and set mb_internal_encoding('UTF-8'); byte functions are correct only when you genuinely mean bytes. There is no char type and no Charset to specify — a string simply holds whatever bytes you put in it. mb_* lives in the mbstring extension, which is not compiled into every PHP build — extension_loaded('mbstring') is worth checking before you rely on it.
Regex is PCRE, in a string
There is no Pattern object to compile and no Matcher to iterate. The pattern is a string carrying its own delimiters — conventionally slashes — with any flags after the closing one.
import java.util.regex.Matcher; import java.util.regex.Pattern; class Main { public static void main(String[] args) { String text = "order 42 shipped"; Matcher matcher = Pattern.compile("order (\\d+)").matcher(text); if (matcher.find()) System.out.println(matcher.group(1)); System.out.println(text.replaceAll("\\d+", "N")); } }
<?php $text = "order 42 shipped"; preg_match('/order (\d+)/', $text, $match); echo $match[1], "\n"; echo preg_replace('/\d+/', "N", $text), "\n";
Matches come back through a by-reference third parameter, and the return value is the count (or false on a malformed pattern), which is why the result is read from $match rather than from the call. Note the escaping: PHP's single-quoted string leaves \d alone, where Java needs \\d in a literal — one of the small daily reliefs. The engine is PCRE rather than java.util.regex, so the syntax is close but not identical (possessive quantifiers and recursion are available; some Java-specific constructs are not).
Control Flow
match is the switch expression
PHP 8's match and Java 14's arrow switch arrived within a year of each other and solve the same three problems: fall-through, statement-ness, and repetitive breaks.
class Main { static String describe(int code) { return switch (code) { case 200, 201 -> "ok"; case 404 -> "missing"; default -> "unknown"; }; } public static void main(String[] args) { System.out.println(describe(201) + " " + describe(404) + " " + describe(500)); } }
<?php declare(strict_types=1); function describe(int $code): string { return match ($code) { 200, 201 => "ok", 404 => "missing", default => "unknown", }; } echo describe(201), " ", describe(404), " ", describe(500), "\n";
Both are expressions, both allow several labels per arm, and both refuse to fall through. Two differences: PHP's match compares with === rather than equals, and an unmatched value with no default throws UnhandledMatchError — the analogue of Java's MatchException. What PHP lacks is pattern matching: there are no type patterns, no record deconstruction and no sealed-type exhaustiveness. The idiomatic replacement for an if/else ladder is match(true) with boolean arms.
Conditions accept anything
Java accepts only a boolean in a condition. PHP gives every value a truth value, and the falsy list is worth memorising because two entries on it are surprising.
import java.util.List; class Main { public static void main(String[] args) { List<Integer> items = List.of(); if (items.isEmpty()) System.out.println("empty"); String name = ""; System.out.println(name.isEmpty() ? "anonymous" : name); } }
<?php $items = []; if (!$items) { echo "empty\n"; } $name = ""; echo $name ?: "anonymous", "\n";
Falsy: false, 0, 0.0, "", "0", [] and null. The string "0" is the one that bites — a form field or a database column arriving as text can be silently falsy. ?: (Elvis) falls back on anything falsy, while ?? falls back only on null or a missing key; reach for ?? unless you genuinely mean "or anything empty". Writing the explicit test (count($items) === 0) is never wrong and is what a Java reader should keep doing until the falsy list is second nature.
Functions & Closures
Named arguments and defaults replace the overloads
PHP has default parameter values and named arguments, which together remove the reason for the overload family Java needs — and PHP has no overloading at all, so there is no alternative.
class Main { static void connect(String host) { connect(host, 5432, 30); } static void connect(String host, int timeout) { connect(host, 5432, timeout); } static void connect(String host, int port, int timeout) { System.out.println(host + ":" + port + " timeout=" + timeout); } public static void main(String[] args) { connect("db.example.com"); connect("db.example.com", 5); } }
<?php declare(strict_types=1); function connect(string $host, int $port = 5432, int $timeout = 30): void { echo "$host:$port timeout=$timeout\n"; } connect("db.example.com"); connect("db.example.com", timeout: 5);
The Java column also shows the flaw the idiom carries: two overloads differing only in an int cannot express which int the caller meant, and connect("host", 5) silently means timeout. Named arguments make that explicit. The new obligation is that a parameter name is now part of your public signature, so renaming one is a breaking change. Variadics are ...$rest, and spreading a keyed array at a call site fills in named arguments.
A closure captures nothing by default
Java lambdas may only capture effectively-final variables, which is why the counter needs an AtomicInteger. PHP closures capture nothing at all unless you list it — and then you choose by value or by reference.
import java.util.concurrent.atomic.AtomicInteger; import java.util.function.IntSupplier; class Main { static IntSupplier makeCounter() { AtomicInteger count = new AtomicInteger(); return () -> count.incrementAndGet(); } public static void main(String[] args) { IntSupplier counter = makeCounter(); System.out.println(counter.getAsInt() + " " + counter.getAsInt()); } }
<?php declare(strict_types=1); function makeCounter(): callable { $count = 0; return function () use (&$count): int { return ++$count; }; } $counter = makeCounter(); echo $counter(), " ", $counter(), "\n";
use ($count) captures the value at the moment the closure is created; use (&$count) captures the variable, which is what makes the counter work. Arrow functions (fn() => ..., 7.4) capture the enclosing scope automatically by value and are limited to one expression. Note that PHP has no block scope and no lexical closure by default — a variable set inside an if is visible after it, and a nested function sees nothing from around it.
Method references, four ways
A function is not a value in PHP the way it is in Java — a named function's name is not a variable — so there are several spellings for "the function called X".
import java.util.List; import java.util.stream.Collectors; class Main { public static void main(String[] args) { List<String> names = List.of("ada", "grace"); System.out.println(names.stream().map(String::toUpperCase).collect(Collectors.joining(","))); } }
<?php declare(strict_types=1); $names = ["ada", "grace"]; echo implode(",", array_map('strtoupper', $names)), "\n"; echo implode(",", array_map(strtoupper(...), $names)), "\n"; // first-class callable
A plain string names a global function; an array names a method ([$object, 'method'] or [Klass::class, 'staticMethod']); and PHP 8.1's first-class callable syntax makes a real Closure out of any of them — strtoupper(...), $object->method(...), Klass::make(...). Prefer that last form for the same reason you prefer String::toUpperCase to reflection: it is checked where it is written. The callable type declaration accepts all of them and says nothing about the signature; a docblock callable(int): string is how PHPStan learns it.
Generators, where Java has none
PHP has real generators — a function containing yield returns a lazy iterator, and nothing in the body runs until it is consumed. Java has no coroutine of this kind at all.
import java.util.stream.Stream; class Main { public static void main(String[] args) { // The nearest thing: a lazy Stream built from an iterate/limit pair. String values = Stream.iterate(3, value -> value - 1) .limit(3) .map(String::valueOf) .collect(java.util.stream.Collectors.joining(",")); System.out.println(values); } }
<?php declare(strict_types=1); function countdown(int $start): Generator { while ($start > 0) { yield $start--; } } echo implode(",", iterator_to_array(countdown(3))), "\n";
The closest Java equivalents are a lazy Stream (as above) or a hand-written Iterator, neither of which lets you write the producer as ordinary sequential code with local state. yield $key => $value produces keys, yield from delegates to another generator, and $generator->send() passes values back in. Generators are the standard way to stream a large database result or a big file without holding it in memory — the same job as a Stream, written the other way round.
Classes & Objects
Constructor promotion, and readonly
PHP 8 constructor promotion collapses the declare-then-assign ritual: a parameter marked with a visibility keyword becomes a property, assigned for you.
class Account { private final String owner; private int balance; Account(String owner) { this(owner, 0); } Account(String owner, int balance) { this.owner = owner; this.balance = balance; } String owner() { return owner; } int balance() { return balance; } void deposit(int amount) { balance += amount; } } class Main { public static void main(String[] args) { Account account = new Account("Ada"); account.deposit(100); System.out.println(account.owner() + " " + account.balance()); } }
<?php declare(strict_types=1); final class Account { public function __construct( public readonly string $owner, private int $balance = 0, ) {} public function balance(): int { return $this->balance; } public function deposit(int $amount): void { $this->balance += $amount; } } $account = new Account("Ada"); $account->deposit(100); echo $account->owner, " ", $account->balance(), "\n";
Three notes. $this-> is an arrow, not a dot, and it is mandatory — a bare $balance inside a method is a local variable. readonly (8.1) makes a property assignable exactly once, from inside the class, which is stronger than final plus a getter and much shorter; a public readonly property needs no accessor at all. And the constructor is named __construct rather than after the class, so there is exactly one — no overloading, which is what default and named arguments replace.
Static members and the double colon
Anything reached through the class rather than an instance uses :: — and remember that a static field here lives only for the current request.
class Counter { static int created = 0; static final String LABEL = "counter"; Counter() { created += 1; } } class Main { public static void main(String[] args) { new Counter(); new Counter(); System.out.println(Counter.created + " " + Counter.LABEL); } }
<?php declare(strict_types=1); final class Counter { public static int $created = 0; public const LABEL = "counter"; public function __construct() { self::$created += 1; } } new Counter(); new Counter(); echo Counter::$created, " ", Counter::LABEL, "\n";
Inside the class, self:: refers to the class the code was written in and static:: to the class actually being called (late static binding), which is the distinction Java has no need for because static methods are not virtual. parent::method() is super.method(). One inconsistency you will mistype: a static property keeps its $ after the colons (Counter::$created) while a const does not.
Inheritance, and no @Override
The shape is identical, including single inheritance and virtual dispatch by default.
class Animal { String speak() { return "..."; } } class Dog extends Animal { @Override String speak() { return "Woof"; } } class Main { public static void main(String[] args) { for (Animal animal : new Animal[] { new Animal(), new Dog() }) { System.out.println(animal.speak()); } } }
<?php declare(strict_types=1); class Animal { public function speak(): string { return "..."; } } final class Dog extends Animal { public function speak(): string { return "Woof"; } } foreach ([new Animal(), new Dog()] as $animal) { echo $animal->speak(), "\n"; }
What is missing is @Override: nothing marks an override and nothing warns when a misspelled name silently becomes a new method — the bug the annotation exists to prevent. Static analysers catch it; the language does not. final works on classes and methods as in Java, abstract too, and a child's method may not narrow visibility. There are no annotations for the compiler to read at all, though attributes (8.0) are the metadata equivalent, which the frameworks section covers.
Magic methods, where Java has reflection
A handful of specially-named methods let a class intercept property access, method calls, string conversion and construction. There is no Java equivalent short of a dynamic proxy.
import java.util.HashMap; import java.util.Map; class Row { private final Map<String, String> columns = new HashMap<>(); void set(String name, String value) { columns.put(name, value); } String get(String name) { return columns.get(name); } } class Main { public static void main(String[] args) { Row row = new Row(); row.set("title", "Hello"); System.out.println(row.get("title")); } }
<?php declare(strict_types=1); /** @property string $title */ final class Row { private array $columns = []; public function __set(string $name, mixed $value): void { $this->columns[$name] = $value; } public function __get(string $name): mixed { return $this->columns[$name] ?? null; } public function __isset(string $name): bool { return isset($this->columns[$name]); } } $row = new Row(); $row->title = "Hello"; // routed through __set echo $row->title, "\n"; // routed through __get
This is how Laravel's Eloquent models expose database columns as properties, and how a great deal of PHP framework magic works. The cost is real: __get and __call are invisible to static analysis and to your editor, which is what the @property docblock is for — it tells PHPStan and PhpStorm what the class pretends to have. The others worth knowing are __toString, __invoke (making an object callable) and __clone. Reach for them when modelling something genuinely dynamic; declare real properties otherwise.
What replaces a record
There is no record, so a value object is a final class with readonly promoted properties — three lines rather than one, and no generated members.
record Point(int x, int y) {} class Main { public static void main(String[] args) { Point first = new Point(1, 2); Point second = new Point(1, 2); System.out.println(first); System.out.println(first.equals(second)); } }
<?php declare(strict_types=1); final class Point { public function __construct( public readonly int $x, public readonly int $y, ) {} public function __toString(): string { return "Point[x=$this->x, y=$this->y]"; } } $first = new Point(1, 2); $second = new Point(1, 2); echo $first, "\n"; echo var_export($first == $second, true), "\n";
What PHP gives you for free is the comparison: == on two objects is true when they are the same class with equal properties, so structural equality needs no equals and no hashCode. === is identity. What you write yourself is __toString, and there is no with-style copy — clone plus readonly properties means a changed copy needs a new constructor call, or PHP 8.3's clone with-adjacent workarounds. Note that a readonly property cannot be reassigned even inside __clone before 8.3.
Interfaces, Traits & Enums
Interfaces, without default methods
PHP interfaces declare methods and constants and cannot carry an implementation — there is no default method. The shared behaviour goes in a trait instead.
interface Greeter { String name(); default String greet() { return "hello, " + name(); } } record Person(String name) implements Greeter {} class Main { public static void main(String[] args) { System.out.println(new Person("Ada").greet()); } }
<?php declare(strict_types=1); interface Greeter { public function name(): string; public function greet(): string; } trait GreeterBehaviour { public function greet(): string { return "hello, " . $this->name(); } } final class Person implements Greeter { use GreeterBehaviour; public function __construct(private string $name) {} public function name(): string { return $this->name; } } echo (new Person("Ada"))->greet(), "\n";
The interface-plus-trait pairing is the idiomatic replacement and is what the standard library does: the interface is the type, the trait is the code. Every interface member is implicitly public. Multiple interface inheritance works as in Java. Note that a trait cannot be used as a type — a parameter typed with a trait name is an error — which is exactly why the interface is still needed.
Traits are compile-time copy-and-paste
A trait can carry state as well as behaviour, which is the line Java's default methods deliberately do not cross.
interface Timestamped { void touch(); String updatedAt(); } class Post implements Timestamped { private String updatedAt; public void touch() { this.updatedAt = "2026-08-18"; } public String updatedAt() { return updatedAt; } } class Main { public static void main(String[] args) { Post post = new Post(); post.touch(); System.out.println(post.updatedAt()); } }
<?php declare(strict_types=1); trait HasTimestamps { public ?string $updatedAt = null; public function touch(): void { $this->updatedAt = "2026-08-18"; } } final class Post { use HasTimestamps; } $post = new Post(); $post->touch(); echo $post->updatedAt, "\n";
It is inserted into the using class at compile time — no extra class in the hierarchy, no runtime lookup, and the methods behave as if you had written them there. Conflicts between two traits are a fatal error you must resolve explicitly with insteadof and as, which is more honest than a linearisation rule. Traits may declare abstract methods (a requirement on the using class) and static properties (one per using class, not shared). This is the feature that makes Laravel's models and Symfony's helpers as compact as they are.
Enums, and the ones that carry a value
PHP 8.1 enums are a real type whose cases are singletons — closer to Java's than to a set of constants, and with a piece of syntax Java lacks.
enum Status { ACTIVE("active"), RETIRED("retired"); private final String raw; Status(String raw) { this.raw = raw; } String raw() { return raw; } String label() { return switch (this) { case ACTIVE -> "still here"; case RETIRED -> "gone"; }; } } class Main { public static void main(String[] args) { System.out.println(Status.ACTIVE.label() + " " + Status.RETIRED.raw()); } }
<?php declare(strict_types=1); enum Status: string { case Active = 'active'; case Retired = 'retired'; public function label(): string { return match ($this) { Status::Active => "still here", Status::Retired => "gone", }; } } echo Status::Active->label(), " ", Status::Retired->value, "\n";
A backed enum (the : string) declares its scalar value in the case list rather than in a constructor, with Status::from('active') to convert in (throwing on an unknown value) and tryFrom returning null instead. ->value reads it back and ->name gives the case name. Status::cases() is values(). A match over every case needs no default, but — unlike Java — adding a case later does not break the compile; it throws UnhandledMatchError at run time. Enums may implement interfaces and hold constants, but not properties.
No sealed types, and no pattern matching
This is the largest gap on the page for a reader who has adopted modern Java. There are no sealed types, no record patterns, and no exhaustiveness checking of any kind.
sealed interface Shape permits Circle, Square {} record Circle(double radius) implements Shape {} record Square(double side) implements Shape {} class Main { static double area(Shape shape) { return switch (shape) { case Circle(double radius) -> 3.14 * radius * radius; case Square(double side) -> side * side; }; } public static void main(String[] args) { System.out.printf("%.2f %.2f%n", area(new Circle(1)), area(new Square(2))); } }
<?php declare(strict_types=1); abstract class Shape {} final class Circle extends Shape { public function __construct(public readonly float $radius) {} } final class Square extends Shape { public function __construct(public readonly float $side) {} } function area(Shape $shape): float { return match (true) { $shape instanceof Circle => 3.14 * $shape->radius ** 2, $shape instanceof Square => $shape->side ** 2, }; } printf("%.2f %.2f\n", area(new Circle(1)), area(new Square(2)));
The closest shape is an abstract base with final subclasses, matched by instanceof inside a match (true). Nothing stops a third subclass appearing in another file, and nothing warns when a match misses it — it throws UnhandledMatchError at run time, which is a real check and not a compile-time one. PHPStan can be told the closed set with an annotation, which is again the analyser doing the compiler's job. There is also no destructuring: the payload is read through properties.
Errors & Exceptions
Nothing is checked
PHP has no throws clause and no checked exceptions. Every exception is what Java calls unchecked: nothing declares it, nothing forces a caller to handle it.
class ConfigException extends Exception { ConfigException(String key) { super("missing " + key); } } class Main { static String lookup(String key) throws ConfigException { throw new ConfigException(key); } public static void main(String[] args) { try { lookup("port"); } catch (ConfigException error) { System.out.println(error.getMessage()); } } }
<?php declare(strict_types=1); final class ConfigException extends RuntimeException { public function __construct(string $key) { parent::__construct("missing $key"); } } /** @throws ConfigException */ function lookup(string $key): string { throw new ConfigException($key); } try { lookup("port"); } catch (ConfigException $error) { echo $error->getMessage(), "\n"; }
The @throws docblock is documentation — though PHPStan and Psalm both read it, and both can be configured to report a call that ignores a documented exception, which gets you most of the way back. catch is typed exactly as in Java and several clauses may follow one try, with catch (AException|BException $error) for alternatives. Since 8.0 the variable is optional: catch (JsonException) is legal when you do not need it.
Error and Exception do not share a parent you would guess
PHP splits throwables into two branches that do not inherit from each other: Exception for what your code is expected to handle, and Error for the language's own failures.
class Main { public static void main(String[] args) { try { Object value = null; value.toString(); } catch (Exception error) { // catches it: NPE is an Exception System.out.println(error.getClass().getSimpleName()); } } }
<?php declare(strict_types=1); try { strlen(null); // TypeError, which is an Error } catch (Exception $error) { echo "not reached\n"; } catch (Error $error) { echo get_class($error), "\n"; }
That inverts a Java habit. NullPointerException, ClassCastException and ArithmeticException are all RuntimeExceptions, so catch (Exception) catches them; in PHP TypeError, ValueError, DivisionByZeroError and ArgumentCountError are Errors, and catch (Exception $e) — which looks like a catch-all — misses every one of them. Catch \Throwable when you mean everything. For your own exceptions extend RuntimeException or LogicException; the SPL hierarchy is shallow and conventional.
Warnings: the failure that does not stop anything
PHP has an older diagnostic channel that Java has no counterpart for: a problem is reported, execution continues, and in a badly configured environment the text lands in the middle of your HTML.
import java.util.List; class Main { public static void main(String[] args) { List<Integer> numbers = List.of(1, 2, 3); try { System.out.println(numbers.get(10)); } catch (IndexOutOfBoundsException error) { System.out.println("IndexOutOfBoundsException"); } } }
<?php $numbers = [1, 2, 3]; echo var_export(@$numbers[10], true), "\n"; // a WARNING; the value is null echo var_export(@$numbers[10] + 1, true), "\n"; // null becomes 0, so this is 1
Reading a missing array index is a warning, not an exception, and the value you get is null. The second line is where it turns dangerous: adding one gives 1, because null converts to 0 — a wrong answer that looks exactly like a right one, where Java would have thrown at the first line. Two habits: read anything that might be absent with ??, and install a set_error_handler that throws ErrorException, which every framework does for you. The @ operator suppresses diagnostics for one expression and is almost always the wrong tool.
No try-with-resources, and no finalizers
There is no AutoCloseable and no try-with-resources: cleanup is an explicit finally.
class Connection implements AutoCloseable { Connection() { System.out.println("open"); } public void close() { System.out.println("close"); } } class Main { public static void main(String[] args) { try (Connection connection = new Connection()) { System.out.println("work"); } System.out.println("after"); } }
<?php declare(strict_types=1); final class Connection { public function __construct() { echo "open\n"; } public function close(): void { echo "close\n"; } public function __destruct() { } } $connection = new Connection(); try { echo "work\n"; } finally { $connection->close(); } echo "after\n";
What PHP does have, and Java gave up, is a deterministic destructor: __destruct runs as soon as the last reference goes away, because the engine is reference-counted with a cycle collector behind it. So an object holding a file handle really does release it at end of scope. It is still not the place for important cleanup — destruction order during shutdown is unspecified, and an exception thrown from a destructor is fatal — but it is closer to RAII than anything on the JVM. And at the end of every request the whole process goes away, which is its own kind of cleanup.
No Threads, and What Replaces Them
There are no threads
Nothing in PHP corresponds to a thread. There is no Thread, no ExecutorService, no synchronized, no volatile, no ConcurrentHashMap and no memory model to reason about.
class Main { public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> System.out.println("worker running")); worker.start(); worker.join(); System.out.println("main finished"); } }
<?php declare(strict_types=1); // PHP: one request, one process, one line after another. function work(): void { echo "worker running\n"; } work(); echo "main finished\n";
That sounds like a limitation and is also why PHP sidestepped a decade of concurrency bugs the JVM world fought: there is no shared mutable state to corrupt, because there is nothing to share it with. Concurrency comes from running more processes — PHP-FPM keeps a pool of workers, and the tuning knob is pm.max_children rather than a thread pool size. The parallel extension exists and is rare; pcntl_fork exists and is for CLI scripts only. Design for a request that does one thing at a time.
What replaces a thread pool
The direct translation of a CompletableFuture pair is a loop, and it is genuinely sequential: two calls take two round trips.
import java.util.List; import java.util.concurrent.CompletableFuture; class Main { public static void main(String[] args) { List<CompletableFuture<Integer>> futures = List.of( CompletableFuture.supplyAsync(() -> 10), CompletableFuture.supplyAsync(() -> 20)); System.out.println(futures.stream().map(CompletableFuture::join).toList()); } }
<?php declare(strict_types=1); // Sequential by default — two round trips, not one: $results = array_map(fn(int $id): int => $id * 10, [1, 2]); echo "[", implode(", ", $results), "]\n"; // The three real answers, in order of how often they are right: // 1. curl_multi_exec — many HTTP requests in parallel in ONE process // 2. a queue worker — Symfony Messenger, Laravel Horizon: separate processes // 3. an event loop — ReactPHP or Amp, built on Fibers, if you can run // your application as a long-lived process
When that is too slow, the three answers above cover nearly every case, and the second is the one most PHP applications reach for — anything slow is pushed to a queue and answered later, which is the same architecture a JVM team would use for work too long for a request thread. What is missing is the middle ground: there is no way to fan out CPU work inside one request, so a PHP application that needs that is usually the wrong tool rather than the wrong design.
Fibers are the scheduler primitive, not a user-facing tool
PHP 8.1 added Fibers: a block of code that can suspend itself and be resumed later. They are the closest thing to a virtual thread, and the comparison needs one important qualification.
class Main { public static void main(String[] args) throws Exception { // Java 21: a virtual thread suspends without blocking a carrier. Thread task = Thread.ofVirtual().start(() -> System.out.println("virtual")); task.join(); System.out.println("done"); } }
<?php declare(strict_types=1); $fiber = new Fiber(function (): void { foreach ([1, 2] as $step) { Fiber::suspend($step); } }); $step = $fiber->start(); while (!$fiber->isTerminated()) { echo "step $step\n"; $step = $fiber->resume(); }
A fiber is not concurrency — there is still one thread and the scheduler is you (or ReactPHP, or Amp). What it shares with a virtual thread is the property that matters: the suspension is invisible to the caller, so an ordinary function deep inside a fiber may suspend and no signature needs marking. That is exactly why Fibers were added, and it is why you will almost certainly never write new Fiber yourself. A virtual thread costs two real threads underneath — a carrier to run it and an unblocker to wake it, one more than the servers that run the Java here allow, so the Java is shown rather than run.
The data race that cannot happen
Two threads incrementing a shared counter is the canonical JVM bug. In PHP the program cannot be written, which is the point of the row.
class Main { static int total = 0; public static void main(String[] args) throws InterruptedException { Runnable work = () -> { for (int i = 0; i < 100_000; i++) total += 1; }; Thread first = new Thread(work); Thread second = new Thread(work); first.start(); second.start(); first.join(); second.join(); System.out.println(total <= 200_000 ? "at most 200000, usually less" : "impossible"); } }
<?php declare(strict_types=1); // The same counter, and no way to race it: one request, one process. $total = 0; for ($index = 0; $index < 200_000; $index++) { $total += 1; } echo $total, "\n"; // exactly 200000, every time
total += 1 is a read, an add and a write, so the Java column loses updates and prints a number well under 200,000 that varies between runs. PHP has nothing to share the variable with, so the answer is exact. (The Java is shown rather than run: the servers that run it here allow only one thread beyond main, and this needs two.) The category of bug that synchronized, AtomicInteger and the Java memory model exist to prevent simply does not arise — and neither do their costs. What replaces the concern is cross-request state: two simultaneous requests hitting the same database row still need a transaction or a lock, and that is where the reasoning moves.
Composer, Namespaces & PSR
Namespaces are not directories, and use imports a name
A namespace is declared inside the file rather than derived from its path, and use imports a name — it loads nothing and runs nothing.
// src/main/java/app/http/Client.java — the path IS the package, // and `package app.http;` would go here (omitted so the example runs). class Client { String send() { return "sent"; } } class Main { public static void main(String[] args) { System.out.println(new Client().send()); } }
<?php declare(strict_types=1); namespace App\Http; use InvalidArgumentException as BadArgument; final class Client { public function send(): string { return "sent"; } } echo (new Client())->send(), "\n"; echo BadArgument::class, "\n";
The separator is a backslash, which is why it must be escaped in double-quoted strings and why example code is full of them; a leading backslash means the global namespace (\strlen(), \Throwable). use X as Y aliases, which Java cannot do. Nothing enforces the path-to-namespace correspondence — PSR-4 is a convention the autoloader relies on, so App\Http\Client lives in src/Http/Client.php because composer.json says the App\ prefix maps to src/. There is also no package-private visibility: public, protected and private are the whole list.
Composer against Maven and Gradle
The mechanics rhyme — a manifest, a dependency directory, a repository — and three things differ in ways that matter on day one.
// pom.xml or build.gradle.kts declares dependencies and the build. // mvn package / ./gradlew build → a jar, on a classpath // Transitive resolution picks the NEAREST version; there is no lockfile // by default, which is where "works on my machine" comes from. class Main { public static void main(String[] args) { System.out.println("compile, package, ship a jar"); } }
<?php // composer require guzzlehttp/guzzle // composer install → vendor/, honouring composer.lock exactly // require __DIR__ . '/vendor/autoload.php'; ← once, at every entry point // No build, no jar: the source IS the artifact. echo "install, autoload, ship the source\n";
First, Composer has a real lockfile and commits it, so composer install is reproducible where mvn package depends on resolution rules. Second, there is no classpath: require vendor/autoload.php once at each entry point and every installed class is available by name, loaded lazily on first use. Third, there is no build — no jar, no shading, no fat-jar plugin — which removes a category of problem and replaces it with deployment questions about opcache. Packagist is Maven Central, and the version constraint syntax is npm-style carets rather than Maven ranges.
PSR is the standards body
PHP has no JCP and no vendor specifications. What it has instead is PHP-FIG, a group of framework maintainers publishing interface packages that everyone agrees to depend on.
// Java has Jakarta EE specifications and a JCP process: // Servlet, JAX-RS, JPA, CDI — implemented by several vendors. class Main { public static void main(String[] args) { System.out.println("a specification, several implementations"); } }
<?php // PSR (PHP Standards Recommendations), from PHP-FIG: // PSR-4 autoloading — how a class name maps to a file // PSR-7 HTTP messages — the Request/Response interfaces // PSR-11 container — dependency injection lookup // PSR-12 coding style — what php-cs-fixer enforces // PSR-3 logger — the interface every library logs through echo "interfaces on Packagist, adopted by convention\n";
The practical effect is the same one Jakarta EE aims at — a library can accept a PSR-7 Request or log through a PSR-3 LoggerInterface without knowing which framework you use — reached by adoption rather than by certification. PSR-4 and PSR-12 are the two you will meet immediately: the first is why your directory layout matters, the second is what php-cs-fixer formats to. There is no reference implementation and no compliance test suite; a package either implements the interface or does not.
Frameworks & Attributes
Attributes are annotations, read by reflection
PHP 8 attributes are annotations with a different syntax and the same job, and the mechanism underneath will be entirely familiar: metadata in the source, read at run time by reflection.
import java.lang.annotation.*; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @interface Route { String value(); } class Controller { @Route("/orders") public void index() {} } class Main { public static void main(String[] args) throws Exception { Route route = Controller.class.getMethod("index").getAnnotation(Route.class); System.out.println(route.value()); } }
<?php declare(strict_types=1); #[Attribute(Attribute::TARGET_METHOD)] final class Route { public function __construct(public readonly string $path) {} } final class Controller { #[Route('/orders')] public function index(): void {} } $method = new ReflectionMethod(Controller::class, 'index'); $attribute = $method->getAttributes(Route::class)[0]; echo $attribute->newInstance()->path, "\n";
The syntax is #[Name(args)], which is a comment to older parsers — that is why it was chosen. An attribute is an ordinary class, so its constructor signature is its parameter list, and newInstance() constructs it. Symfony uses them for routing and dependency injection, Doctrine for entity mapping, PHPUnit for test metadata — the same places Spring and JPA use annotations. Before 8.0 the ecosystem read the same metadata out of docblock comments, which you will still meet in older Doctrine and Symfony code.
The framework map
The reason a Java reader is on this page is usually a Symfony or Laravel codebase, and the pieces map more directly than the syntax suggests.
// Spring Boot: // @RestController + @GetMapping("/orders") // constructor injection from an application context // JPA/Hibernate entities, Flyway migrations, Jackson for JSON class Main { public static void main(String[] args) { System.out.println("annotations, a container, and a long-lived context"); } }
<?php // Symfony (the closest in shape to Spring): // #[Route('/orders')] on a controller method // autowired constructor injection from a PSR-11 container // Doctrine ORM entities, Doctrine Migrations, the Serializer component // // Laravel (the closest in shape to Rails): // routes/web.php declares routes; Eloquent is active-record, not a data mapper // a service container with facades, and migrations as PHP classes echo "the same pieces, rebuilt each request\n";
Symfony is the closer analogue: a container that autowires constructors, attributes for routing, and Doctrine as a data-mapper ORM with the same identity-map and unit-of-work ideas as JPA. Laravel is Rails-shaped — Eloquent is active record, so the model is the table row, which has no JPA equivalent and is the bigger adjustment. The one structural difference behind both: the container is rebuilt every request, so it is compiled and cached to a file rather than assembled at startup, and a "singleton" is a singleton for one request only.