Output & Running
Hello, World
There is no opening tag. PHP is a templating language that happens to compute — everything outside
<?php is literal output, which is why every file has to announce where the code starts. An Elixir file is code from the first byte, so there is nothing to announce.<?php
echo "Hello, World!\n";IO.puts("Hello, World!")IO.puts appends the newline for you, which is why there is no \n here. Its sibling IO.write does not. The parentheses are optional in Elixir — IO.puts "Hello, World!" is the same call — but the community style guide asks for them everywhere except a handful of macros, so this page keeps them.var_dump and print_r have one replacement
PHP gives you two debugging printers with different jobs:
print_r is readable and var_dump carries types. Elixir has one, IO.inspect, and it does both — because Elixir's printed form of a value is also valid Elixir source, so showing you the value and showing you its type are the same act.<?php
$person = ["name" => "Ada", "age" => 36];
print_r($person);
var_dump(42);
echo "still here\n";person = %{name: "Ada", age: 36}
IO.inspect(person)
IO.inspect(42)
IO.puts("still here")The detail that changes how you debug:
IO.inspect returns its argument, so you can drop it into the middle of a pipeline without restructuring anything — list |> IO.inspect() |> Enum.sum() prints and carries on. print_r returns true (or the string, with its second argument), so it can never be spliced into an expression that way. Add label: to tell several inspects apart: IO.inspect(person, label: "before").Running code, and where the REPL fits
Both languages have a file mode, a one-liner mode and a REPL, and the mapping is one to one. What differs is how much you will use the REPL:
php -a is a curiosity most PHP developers touch once a year, while iex is where Elixir developers spend a large part of the day.<?php
// Saved as greet.php, run with: php greet.php
// Or interactively: php -a
// Or as a one-liner: php -r 'echo 1 + 1;'
echo 1 + 1, "\n";# Saved as greet.exs, run with: elixir greet.exs
# Or interactively: iex
# Or as a one-liner: elixir -e "IO.puts(1 + 1)"
IO.puts(1 + 1)The reason is that
iex can attach to a running system. An Elixir server keeps running between requests, so you can open a shell into production, inspect live state, and call functions on it — something PHP structurally cannot offer, because by the time you would look, the process that served the request is gone. The .exs extension means "script": it is compiled in memory each time. Compiled projects use .ex files and produce .beam bytecode.Variables & Immutability
No sigil on a variable
PHP marks every variable with
$, which is what lets it drop a variable into a double-quoted string with no delimiters at all. Elixir has no sigil, so interpolation needs explicit brackets: #{}.<?php
$userName = "Ada";
$loginCount = 3;
echo "{$userName} has logged in {$loginCount} times\n";user_name = "Ada"
login_count = 3
IO.puts("#{user_name} has logged in #{login_count} times")Losing the
$ costs you one real thing: PHP can tell a variable from a function call by looking at it, and Elixir cannot. A bare foo in Elixir is a variable if one is in scope and a zero-argument call to a local function otherwise, which is occasionally surprising and is why the compiler warns when you shadow a function name. The naming convention is snake_case for variables and functions, and CamelCase for module names only.Nothing is ever modified in place
PHP arrays are value types with copy-on-write, so this exact example already behaves the way a PHP developer expects. That makes it the friendliest possible starting point — and also the one that hides the difference, so read the note.
<?php
$numbers = [1, 2, 3];
$copy = $numbers;
$copy[] = 4; // modifies $copy only — PHP arrays are copy-on-write
print_r($numbers); // still 1, 2, 3
print_r($copy); // 1, 2, 3, 4numbers = [1, 2, 3]
copy = numbers
copy = copy ++ [4] # builds a NEW list; nothing was modified
IO.inspect(numbers) # still 1, 2, 3
IO.inspect(copy) # 1, 2, 3, 4The difference is what happens with objects.
$copy = $someObject in PHP copies a handle, so mutating through $copy is visible through the original; that is the bug class every PHP codebase has hit. In Elixir there is no such case, because no operation anywhere in the language modifies an existing value. copy ++ [4] does not append — it constructs a new list and rebinds the name copy to it. That guarantee is not a style preference; it is what makes it safe for thousands of concurrent processes to hold references to the same data, which is the subject of the Processes section below.= is a match, not an assignment
This is the single idea that makes Elixir feel foreign, and it is worth ten minutes.
= is not assignment. It is the match operator: it takes a pattern on the left and a value on the right, binds any unbound names in the pattern, and raises a MatchError if the shapes do not line up.<?php
$point = [3, 4];
// PHP has destructuring, but = is still assignment:
[$x, $y] = $point;
echo "x={$x} y={$y}\n";
// A comparison is a separate operator, and its result is a value:
var_dump(3 === $x);point = {3, 4}
# The SAME operator that binds also asserts:
{x, y} = point
IO.puts("x=#{x} y=#{y}")
# An unbound name on the left binds; a pinned one must match:
{3, ^y} = point
IO.puts("the assertion held")So
{x, y} = point does two jobs at once — it destructures like PHP's [$x, $y] = $point, and it asserts that point really is a two-element tuple. The literal 3 in the second match is a pattern too, so that line is an executable claim about the first element. ^y is the pin operator: it says "do not rebind y, match against the value it already holds". Without the caret, y would simply be rebound and the assertion would be vacuous.Rebinding a name is not mutating a value
People often hear "Elixir is immutable" and conclude that a name can only be bound once. It cannot mean that, or you could not write a loop counter — so here is the proof that rebinding is perfectly ordinary.
<?php
$greeting = "hello";
$shout = strtoupper($greeting);
$greeting = "goodbye"; // the variable now points elsewhere
echo $shout, "\n"; // HELLO
echo $greeting, "\n"; // goodbyegreeting = "hello"
shout = String.upcase(greeting)
greeting = "goodbye" # the name now points elsewhere
IO.puts(shout) # HELLO
IO.puts(greeting) # goodbyeThe distinction is between the name and the value. Rebinding
greeting points the name at a different string; it does not reach into the old string and change it, so shout, which captured the result of the old one, is untouched. In practice you rebind far less than you reassign in PHP, because a pipeline usually replaces a sequence of reassignments. Erlang, Elixir's substrate, forbids rebinding entirely — Elixir relaxed that specifically because the Erlang style forces names like Numbers1, Numbers2, Numbers3.Atoms & the Type Zoo
Atoms — the type PHP does not have
An atom is a named constant that needs no declaration and whose value is its own name. PHP has no equivalent — the closest things are a string used as a marker and, since PHP 8.1, an enum case. The difference from both is worth understanding, because atoms are everywhere in Elixir code.
<?php
// PHP reaches for a string, a class constant, or an enum:
$status = "ok";
if ($status === "ok") {
echo "it worked\n";
}
// A typo is a silent bug:
$typo = "0k";
var_dump($typo === "ok"); // false, and nothing complains# An atom is a constant whose name IS its value:
status = :ok
if status == :ok do
IO.puts("it worked")
end
# A typo is still just a different atom, but it is cheap and comparable:
typo = :"0k"
IO.inspect(typo == :ok) # falseTwo properties make atoms carry so much weight. They compare in constant time regardless of length, because the runtime interns each one to an integer — so
:some_very_long_status_name costs no more to compare than :ok, while two equal PHP strings are compared character by character. And they are self-describing without a class: you do not declare :ok anywhere, which is why library authors return {:ok, value} and {:error, reason} without first agreeing on a shared enum. The cost is that the atom table is never garbage collected, so never call String.to_atom on user input — use String.to_existing_atom, which raises rather than growing the table.true, false and nil are atoms too
true, false and nil are not separate types in Elixir. They are ordinary atoms with a little syntax sugar — true is exactly :true, and you may write either. What matters for a PHP developer is the consequence for truthiness.<?php
var_dump(gettype(true));
var_dump(gettype(null));
// PHP has a long list of falsy values:
foreach ([0, "", "0", [], null, false] as $value) {
echo var_export($value, true), " => ", $value ? "truthy" : "falsy", "\n";
}IO.inspect(is_atom(true))
IO.inspect(is_atom(nil))
# Elixir has exactly two falsy values:
for value <- [0, "", "0", [], nil, false] do
label = if value, do: "truthy", else: "falsy"
IO.puts("#{inspect(value)} => #{label}")
endPHP's falsy list is famously long, and the entries that catch people are
"0" — a non-empty string that is nonetheless falsy — and []. Elixir's list has exactly two entries: false and nil. Zero is truthy. The empty string is truthy. The empty list is truthy. If you are used to writing if ($items) to mean "if there are any items", that idiom silently stops working, because the empty list passes. Write if items != [], or unless Enum.empty?(items).Tuples, and the tagged-return convention
A tuple is a fixed-size ordered container written with braces. On its own that is unremarkable — but combined with atoms it produces the convention that shapes every Elixir API you will ever call.
<?php
// PHP returns a bare value and signals failure out of band:
$decoded = json_decode('{"name": "Ada"}', true);
if ($decoded === null) {
echo "decoding failed: ", json_last_error_msg(), "\n";
} else {
echo $decoded["name"], "\n";
}# Elixir returns a tagged tuple; success and failure have the same shape:
decoded = {:ok, %{"name" => "Ada"}}
case decoded do
{:ok, person} -> IO.puts(person["name"])
{:error, reason} -> IO.puts("decoding failed: #{inspect(reason)}")
endThe convention is that a function which can fail returns
{:ok, value} or {:error, reason}. Compare what PHP must do here: json_decode returns null on failure, but null is also the correct decoding of the input "null", so the two are indistinguishable and you have to call json_last_error() to tell them apart. A tagged tuple has no such ambiguity, and because case is a pattern match rather than a chain of comparisons, forgetting the error branch is a loud CaseClauseError rather than a null that travels three functions before it explodes. Tuples are for a known, small number of elements; use a list when the count varies.Checking types, and why there is no is_object
PHP has one function,
gettype, that names the type. Elixir has a family of is_* predicates and no single namer, because the checks exist primarily to be used as guards — the small, side-effect-free expressions the compiler allows in a function head or a case clause.<?php
$values = [42, 3.5, "text", [1, 2], null, true];
foreach ($values as $value) {
echo gettype($value), "\n";
}values = [42, 3.5, "text", [1, 2], nil, true]
for value <- values do
cond do
is_integer(value) -> IO.puts("integer")
is_float(value) -> IO.puts("float")
is_binary(value) -> IO.puts("binary (a string)")
is_list(value) -> IO.puts("list")
is_nil(value) -> IO.puts("nil")
is_boolean(value) -> IO.puts("boolean")
end
endNote that a string reports as a binary, which is literally what it is: a chunk of bytes, as the Strings section explains. There is no
is_object because there are no objects — a struct reports as a map, which is what it is underneath. Two ordering details also differ from PHP: is_boolean must be tested before is_atom would match, since booleans are atoms; and in iex, i(value) is the interactive equivalent of gettype and prints a paragraph about any value you hand it.Strings & Binaries
Single quotes do NOT mean what they mean in PHP
Put this one first among the string rows, because it costs PHP developers an afternoon. In PHP the two quote characters produce the same type and differ only in whether escapes and interpolation are processed. In Elixir they produce different types.
<?php
$double = "hello";
$single = 'hello';
var_dump($double === $single); // true — same string, different escaping rules
echo strlen($single), "\n";double = "hello"
single = ~c"hello" # a charlist: the LIST [104, 101, 108, 108, 111]
IO.inspect(double == single) # false — different types entirely
IO.inspect(is_list(single))
IO.inspect(byte_size(double))A double-quoted Elixir literal is a binary — a UTF-8 byte sequence, and what everyone means by "string". A single-quoted literal is a charlist: an ordinary list of Unicode code points, which is what Erlang has always called a string. They are not interchangeable, they do not compare equal, and
String.upcase will reject a charlist. Elixir 1.15 began asking for the explicit ~c"" sigil precisely because the bare single-quoted form misled so many newcomers. You will still meet charlists when calling Erlang libraries directly; List.to_string/1 and String.to_charlist/1 convert.Interpolation, and why . becomes <>
Interpolation maps directly, with
#{} in place of {$…}. Concatenation does not: PHP's . is taken (it is the module separator), so Elixir uses <> — and unlike . it will not silently convert a number for you.<?php
$name = "Ada";
$age = 36;
echo "Name: {$name}, age: {$age}\n";
echo "Name: " . $name . ", age: " . $age . "\n";
echo 'Not interpolated: {$name}', "\n";name = "Ada"
age = 36
IO.puts("Name: #{name}, age: #{age}")
IO.puts("Name: " <> name <> ", age: " <> Integer.to_string(age))
IO.puts(~S"Not interpolated: #{name}")That refusal is the point.
"age: " . 36 in PHP quietly stringifies, which is the same juggling that makes "1" == 1 true; "age: " <> 36 in Elixir is an ArgumentError, and you must say Integer.to_string(age). Interpolation is the exception — #{age} works on any value, because it calls the String.Chars protocol. ~S is the sigil for a string with no interpolation and no escape processing, which is what PHP's single quotes do.The string library, and no mb_ prefix anywhere
The names map almost one to one, with two conventions to absorb: every function lives in the
String module rather than the global namespace, and a predicate ends in a question mark.<?php
$text = " Hello, World ";
echo strtoupper(trim($text)), "\n";
echo str_replace("World", "Elixir", trim($text)), "\n";
var_dump(str_contains($text, "World"));
echo substr(trim($text), 0, 5), "\n";text = " Hello, World "
IO.puts(String.upcase(String.trim(text)))
IO.puts(String.replace(String.trim(text), "World", "Elixir"))
IO.inspect(String.contains?(text, "World"))
IO.puts(String.slice(String.trim(text), 0, 5))The real difference is what is missing. PHP has
strtoupper and mb_strtoupper, strlen and mb_strlen, because its original string functions are byte-oriented and Unicode support was bolted on as a separate extension you must remember to use — and which plenty of PHP builds do not compile in at all. Elixir has one of each, and it is always the Unicode-correct one: String.length("héllo") is 5 everywhere, with nothing to install and no prefix to remember. The question mark in contains? is a naming convention, not syntax; a trailing ! means "raises instead of returning an error tuple".Characters and bytes are different questions
PHP's
strlen counts bytes and always has; getting a character count means reaching for the mbstring extension. Elixir keeps both questions available but makes you name which one you are asking.<?php
$text = "héllo";
echo mb_strlen($text), "\n"; // 5 — characters
echo strlen($text), "\n"; // 6 — bytes
echo json_encode(mb_str_split($text), JSON_UNESCAPED_UNICODE), "\n";text = "héllo"
IO.puts(String.length(text)) # 5 — characters
IO.puts(byte_size(text)) # 6 — bytes
IO.inspect(String.graphemes(text))The naming tells you where each function lives in the mental model.
byte_size is a plain kernel function on a binary — it is O(1), because a binary knows its own length. String.length is in the String module and walks the UTF-8, so it is O(n). That is the opposite of the PHP habit, where the cheap call is the one you reach for by default and the correct one costs an extension; here the correct one is the default and the cheap one is explicit. String.graphemes goes one level further than code points and groups combining marks, so an "e" followed by a combining accent counts as one.Splitting and joining
PHP's pair is
explode and implode, whose argument order has confused people for twenty years. Elixir splits the pair across two modules on a principle: splitting is a string operation, joining is a collection operation.<?php
$line = "ada,grace,alan";
$names = explode(",", $line);
print_r($names);
echo implode(" and ", $names), "\n";line = "ada,grace,alan"
names = String.split(line, ",")
IO.inspect(names)
IO.puts(Enum.join(names, " and "))That placement is not pedantry — it tells you where to look for related functions. Anything that takes a string and produces something is in
String; anything that takes a list and reduces it is in Enum, alongside Enum.map and Enum.reduce. Both take the subject first, so there is no order to memorize, and that consistency is what makes the pipe operator work at all. String.split(line) with no second argument splits on whitespace, the way preg_split('/\s+/', $line) would.One Array Becomes Four
PHP has one array; Elixir has four collections
This is the structural change with the widest reach. PHP's array is an ordered hash map that stands in for a list, a record, a tuple and an options bag, so you never choose. Elixir makes you choose, and the choice has performance consequences you can feel.
<?php
// One data structure does all four jobs:
$list = [1, 2, 3];
$record = ["name" => "Ada", "age" => 36];
$pair = [3, 4];
$options = ["sort" => true, "limit" => 10];
echo count($list), " ", $record["name"], " ", $pair[0], "\n";# Four data structures, each with different costs and rules:
list = [1, 2, 3] # linked list: prepend O(1), index O(n)
map = %{name: "Ada", age: 36} # hash map: lookup O(log n)
tuple = {3, 4} # fixed size: index O(1), no growth
options = [sort: true, limit: 10] # keyword list: ordered, duplicate keys ok
IO.puts("#{length(list)} #{map.name} #{elem(tuple, 0)}")Read the costs in the comments, because they drive Elixir style. A list is a singly linked list, so prepending is free and appending walks the whole thing — which is why idiomatic code builds lists backwards and calls
Enum.reverse once at the end, and why there is no cheap $list[5]. A map is the general key-value store. A tuple is contiguous in memory, so indexing is instant but growing it copies. A keyword list is just a list of two-element tuples with atom keys, which is why it keeps insertion order and permits duplicates — it is what Elixir uses for optional function arguments, the role PHP fills with an associative array.The head-and-tail shape of a list
PHP's
array_shift and array_unshift reach into an array and change it. The Elixir equivalents are not functions at all — they are the | operator, called the cons operator, used once as a pattern and once as a constructor.<?php
$numbers = [1, 2, 3, 4];
$first = array_shift($numbers); // mutates $numbers
echo $first, "\n";
print_r($numbers);
array_unshift($numbers, 0); // mutates again
print_r($numbers);numbers = [1, 2, 3, 4]
[first | rest] = numbers # destructures; nothing is mutated
IO.puts(first)
IO.inspect(rest)
with_zero = [0 | numbers] # a new list sharing the old one's tail
IO.inspect(with_zero)The symmetry is deliberate:
[first | rest] on the left of a match takes a list apart, and [0 | numbers] on the right puts one together. Prepending is O(1) and, crucially, with_zero and numbers share the same three cells in memory — safe precisely because nothing can ever modify them. This shape is why so much Elixir is written as recursion over [head | tail], and why array_pop has no cheap counterpart: reaching the last element of a linked list means walking it.Associative arrays become maps
A map is the closest thing to a PHP associative array, with one syntactic bonus: when the keys are atoms you can write them as
name: and read them back with a dot.<?php
$person = ["name" => "Ada", "age" => 36];
echo $person["name"], "\n";
$older = $person;
$older["age"] = 37; // copy-on-write, so $person is untouched
print_r($older);
var_dump(array_key_exists("email", $person));person = %{name: "Ada", age: 36}
IO.puts(person.name)
older = %{person | age: 37} # update syntax: returns a NEW map
IO.inspect(older)
IO.inspect(Map.has_key?(person, :email))The
%{map | key: value} form is the update syntax, and it has a safety property worth knowing: it raises if the key does not already exist. That makes it a typo-catcher for records whose shape you believe you know, in exchange for being useless when you are adding a key — for that, use Map.put(person, :email, "a@b.c"). The dot access person.name also raises on a missing key, while person[:name] returns nil, which is the same split PHP has between $person["name"] (a warning and null) and $person["name"] ?? $default.String keys and atom keys are different keys
PHP normalizes keys: a numeric string becomes an integer,
true becomes 1, and null becomes the empty string. Elixir does none of this, which means a map can hold both "id" and :id at once, and confusing them is a real bug you will hit on your first day with Phoenix.<?php
// PHP has one kind of key, and it juggles:
$data = ["id" => 1];
var_dump(isset($data["id"]));
$numeric = ["1" => "a"];
var_dump(array_key_exists(1, $numeric)); // true — "1" became int 1# Two kinds of key, and they never juggle:
data = %{"id" => 1, id: 2}
IO.inspect(data["id"]) # 1 — the string key
IO.inspect(data[:id]) # 2 — the atom key
IO.inspect(map_size(data)) # 2 — they are genuinely different entriesThe practical rule: data from outside the system has string keys, data you wrote has atom keys. A decoded JSON body or a form submission arrives as
%{"email" => …}, and if you write params.email you get a KeyError. That is deliberate, not an oversight — automatically converting incoming keys to atoms would let a hostile client fill the never-collected atom table. Phoenix's changesets do the conversion for you at a controlled boundary, which is where it belongs.Keyword lists are how options are passed
PHP's options-array idiom — an associative array of settings with
?? defaults — has a direct counterpart, and it is the keyword list rather than the map. The syntax rewards it: a trailing keyword list needs no brackets at all.<?php
function report(array $rows, array $options = []): void {
$separator = $options["separator"] ?? ", ";
$upper = $options["upper"] ?? false;
$line = implode($separator, $rows);
echo $upper ? strtoupper($line) : $line, "\n";
}
report(["ada", "grace"]);
report(["ada", "grace"], ["separator" => " | ", "upper" => true]);report = fn rows, options ->
separator = Keyword.get(options, :separator, ", ")
upper = Keyword.get(options, :upper, false)
line = Enum.join(rows, separator)
IO.puts(if upper, do: String.upcase(line), else: line)
end
report.(["ada", "grace"], [])
report.(["ada", "grace"], separator: " | ", upper: true)That last call reads as if the language had named arguments, and it is why nearly every Elixir library takes options this way. Underneath,
separator: " | ", upper: true is exactly [{:separator, " | "}, {:upper, true}] — a list of tuples, which is why order is kept and why the same key may appear twice (Keyword.get returns the first). Use a keyword list for options and a map for data: options are a small fixed set written by a programmer, data is arbitrary and wants O(log n) lookup rather than a linear scan.Pattern Matching
Destructuring nested data
PHP pulls nested values out one path at a time. A pattern does it in one expression by drawing the shape of the data and putting names where the values are.
<?php
$response = ["status" => 200, "body" => ["name" => "Ada", "roles" => ["admin", "dev"]]];
$status = $response["status"];
$name = $response["body"]["name"];
$firstRole = $response["body"]["roles"][0];
echo "{$status} {$name} {$firstRole}\n";response = %{status: 200, body: %{name: "Ada", roles: ["admin", "dev"]}}
%{status: status, body: %{name: name, roles: [first_role | _]}} = response
IO.puts("#{status} #{name} #{first_role}")Read the pattern as a picture of the value, with holes. The underscore in
[first_role | _] is the anonymous match — it accepts anything and binds nothing, and the compiler will not warn about it being unused. There is a second thing happening here that PHP's version does not do: a map pattern is a partial match, so this asserts the response has a status and a body with a name, and it raises immediately if the shape is wrong. The PHP version would sail past a missing body and hand you null to trip over later.match becomes case, and it matches shapes
PHP 8's
match is already an expression and already compares strictly, so this is familiar ground. The difference is what each arm may be: PHP compares a subject against values, while case compares against patterns, so a branch can take the value apart at the same moment it decides.<?php
$results = [
["ok", 42],
["error", "timed out"],
["ok", 7],
];
foreach ($results as $result) {
echo match ($result[0]) {
"ok" => "got {$result[1]}",
"error" => "failed: {$result[1]}",
}, "\n";
}results = [
{:ok, 42},
{:error, "timed out"},
{:ok, 7}
]
for result <- results do
IO.puts(
case result do
{:ok, value} -> "got #{value}"
{:error, reason} -> "failed: #{reason}"
end
)
endNotice that the PHP version has to index back into
$result inside the arm, because matching on $result[0] threw away the connection to $result[1]. The Elixir branch binds value and reason as part of choosing the branch. Both languages raise when nothing matches — PHP an UnhandledMatchError, Elixir a CaseClauseError — which is the good behavior; switch falling silently through is the thing neither does.Guards add conditions to a pattern
A guard is a condition attached to a pattern, written after
when in a function head or a case clause. Here it is shown through cond, which is the closest thing to a chain of ifs and needs no pattern at all.<?php
function describe(mixed $value): string {
if (is_int($value) && $value < 0) {
return "a negative integer";
}
if (is_int($value)) {
return "an integer";
}
if (is_string($value) && strlen($value) > 5) {
return "a long string";
}
return "something else";
}
foreach ([-3, 7, "elephant", 2.5] as $value) {
echo describe($value), "\n";
}describe = fn value ->
cond do
is_integer(value) and value < 0 -> "a negative integer"
is_integer(value) -> "an integer"
is_binary(value) and byte_size(value) > 5 -> "a long string"
true -> "something else"
end
end
for value <- [-3, 7, "elephant", 2.5] do
IO.puts(describe.(value))
endcond is the tool for "several unrelated conditions, take the first true one" — exactly the shape of the PHP function beside it — and its final true -> is the else. Without it, no branch matching raises a CondClauseError. Guards are deliberately restricted: only a fixed list of side-effect-free operations is allowed, so you cannot call your own function in one. That looks limiting until you see why — a guard runs during matching, possibly many times, and the compiler must be able to prove it terminates and cannot alter anything.Several function heads instead of one branching body
This is where pattern matching stops being a convenience and changes how you write code. Instead of one function whose body branches on the input, you write several functions with the same name, and the runtime picks the one whose pattern fits.
<?php
function area(array $shape): float {
switch ($shape["type"]) {
case "circle":
return M_PI * $shape["radius"] ** 2;
case "rectangle":
return $shape["width"] * $shape["height"];
default:
throw new InvalidArgumentException("unknown shape");
}
}
printf("%.2f\n", area(["type" => "circle", "radius" => 2]));
printf("%.2f\n", area(["type" => "rectangle", "width" => 3, "height" => 4]));defmodule Shape do
def area(%{type: :circle, radius: radius}), do: :math.pi() * radius ** 2
def area(%{type: :rectangle, width: width, height: height}), do: width * height
end
IO.puts(:erlang.float_to_binary(Shape.area(%{type: :circle, radius: 2}), decimals: 2))
IO.puts(:erlang.float_to_binary(Shape.area(%{type: :rectangle, width: 3, height: 4}) / 1, decimals: 2))The clauses are tried top to bottom, so order matters and the specific ones go first. There is no
default branch here and that is deliberate: an unrecognized shape raises a FunctionClauseError naming the function and the argument that did not match, which is a better error than the InvalidArgumentException you had to remember to write. def name(args), do: body is the one-line form of def name(args) do … end. And notice the calls out to Erlang — :math and :erlang are Erlang modules addressed as atoms, which is how Elixir reaches the whole OTP standard library with no wrapper.Functions & Modules
A module is a namespace, not a class
A
defmodule looks like a class and is not one. It has no constructor, no instance, no $this and no inheritance — it is a named bag of functions, closer to a PHP class holding only static methods, or to a namespace.<?php
class Greeter {
public function __construct(private string $greeting) {}
public function greet(string $name): string {
return "{$this->greeting}, {$name}!";
}
}
$greeter = new Greeter("Hello");
echo $greeter->greet("Ada"), "\n";defmodule Greeter do
def greet(greeting, name) do
"#{greeting}, #{name}!"
end
end
IO.puts(Greeter.greet("Hello", "Ada"))The consequence is that state which a PHP object would hold as a property becomes an explicit argument. That feels like a loss until you notice what it buys: any function can be called with any data, so there is no object to construct before testing, no partially initialized state, and no question of which methods are safe to call in which order. When you genuinely need state that persists and is shared, the answer is a process rather than an object — see the State section. Module names are
CamelCase atoms; Greeter is really :"Elixir.Greeter", which is why calling Erlang modules looks like :math.pi().Arity is part of the name
In Elixir a function is identified by its name and its number of arguments, written
greet/2. greet/1 and greet/2 are two different functions that happen to share a name, which is why every error message and every documentation page carries that slash.<?php
// One function, optional parameters:
function greet(string $name, string $greeting = "Hello"): string {
return "{$greeting}, {$name}!";
}
echo greet("Ada"), "\n";
echo greet("Ada", "Welcome"), "\n";
// PHP cannot declare a second greet() with a different parameter count.defmodule Greeting do
# Two SEPARATE functions: greet/1 and greet/2.
def greet(name), do: greet(name, "Hello")
def greet(name, greeting), do: "#{greeting}, #{name}!"
end
IO.puts(Greeting.greet("Ada"))
IO.puts(Greeting.greet("Ada", "Welcome"))The idiom above — a shorter arity that delegates to a longer one with a default filled in — is so common that Elixir has sugar for it:
def greet(name, greeting \\ "Hello") defines both greet/1 and greet/2 in one line, and is what you would normally write. Seeing it spelled out is worth it once, because it explains an error message that puzzles newcomers: undefined function greet/1 when greet/2 is plainly defined right there. PHP has no equivalent, since a PHP function name is unique in its namespace regardless of parameters.Anonymous functions, and the dot you must not forget
PHP calls an anonymous function stored in a variable exactly like a named one:
$double(21). Elixir requires a dot before the parentheses, and leaving it out is the most common syntax error a newcomer makes.<?php
$double = fn(int $value): int => $value * 2;
$add = function (int $left, int $right): int {
return $left + $right;
};
echo $double(21), "\n";
echo $add(20, 22), "\n";double = fn value -> value * 2 end
add = fn left, right -> left + right end
IO.puts(double.(21))
IO.puts(add.(20, 22))The dot is not decoration; it resolves a genuine ambiguity that PHP avoids with its
$. Without a sigil, double(21) already means "call the local named function double/1", so there has to be some other spelling for "call the function this variable holds" — and that is double.(21). Note also that an Elixir anonymous function may have multiple clauses with patterns, which no PHP closure can do: fn {:ok, value} -> value; {:error, _} -> nil end. Capturing is automatic and by value; there is no use clause, because nothing captured could be mutated anyway.Passing a named function as a value
PHP's oldest way to pass a function is a string holding its name, which nothing checks until the call happens. PHP 8.1 added
strtoupper(...) to fix that. Elixir's equivalent is the capture operator &, and it has always been checked.<?php
$words = ["ada", "grace", "alan"];
// A string names the function — resolved at run time, unchecked:
print_r(array_map("strtoupper", $words));
// First-class callable syntax (PHP 8.1+) is checked at compile time:
print_r(array_map(strtoupper(...), $words));words = ["ada", "grace", "alan"]
# The capture operator names module, function and arity:
IO.inspect(Enum.map(words, &String.upcase/1))
# It also builds a short anonymous function, where &1 is the first argument:
IO.inspect(Enum.map(words, &(&1 <> "!")))&String.upcase/1 is a reference to a specific function of a specific arity, and the compiler will tell you at build time if it does not exist — the same guarantee as PHP's first-class callable syntax, with the arity spelled out. The second form is different and is the one you will write most: &(&1 <> "!") is shorthand for fn value -> value <> "!" end, where &1, &2 and so on are the positional arguments. It is best kept to a single short expression; past that, write the fn out.There is no return statement
Elixir has no
return keyword, and there is no way to leave a function early. The value of a function is the value of its last expression, and since every construct is an expression — if, case, cond all produce values — that is enough.<?php
function classify(int $score): string {
if ($score >= 90) {
return "excellent"; // leaves the function immediately
}
if ($score < 0) {
return "invalid";
}
return "ordinary";
}
echo classify(95), " ", classify(-1), " ", classify(50), "\n";classify = fn score ->
cond do # the LAST expression is the return value
score >= 90 -> "excellent"
score < 0 -> "invalid"
true -> "ordinary"
end
end
IO.puts("#{classify.(95)} #{classify.(-1)} #{classify.(50)}")The habit this breaks is the guard-clause style:
if (!$user) return null; at the top of a PHP method, repeated four times before the real work starts. Elixir replaces it in one of two ways. For validation chains, with (in Control Flow below) reads like a series of early returns and is the direct answer. For dispatch on shape, multiple function heads do it — each clause is an early return, decided before the body runs. Once you stop looking for return, functions get shorter, because a function that wanted three early exits usually wanted to be three clauses.The Pipe Operator
Reading inside-out becomes reading left to right
The PHP line is read from the middle outwards: find
explode, then look left for the array_map wrapping it, then left again. The pipe operator takes the value on its left and passes it as the first argument of the call on its right, so the steps appear in the order they happen.<?php
$line = " ada, grace, alan ";
$names = array_map("ucfirst", array_map("trim", explode(",", trim($line))));
print_r($names);line = " ada, grace, alan "
names =
line
|> String.trim()
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.map(&String.capitalize/1)
IO.inspect(names)This is why every Elixir standard-library function takes its subject first —
String.split(string, pattern), Enum.map(collection, function), Map.put(map, key, value). That consistency is what makes pipelines composable, and its absence is exactly why PHP's array_map($fn, $array) and array_filter($array, $fn) cannot chain: the subject is in a different position in each. PHP 8.5 finally added a |> of its own, but it pipes into a single-argument callable rather than into a first parameter, so the two are not the same tool.A pipeline is not a fluent interface
Laravel's collections give PHP the same left-to-right reading, so it is fair to ask what the pipe adds. The answer is where the operations live: a fluent chain needs every step to be a method defined on the class, while a pipeline needs only a function whose first parameter fits.
<?php
// A fluent chain requires each step to be a METHOD on the object:
class Collection {
public function __construct(private array $items) {}
public function map(callable $callback): static { return new static(array_map($callback, $this->items)); }
public function filter(callable $callback): static { return new static(array_values(array_filter($this->items, $callback))); }
public function all(): array { return $this->items; }
}
$result = (new Collection([1, 2, 3, 4, 5]))
->filter(fn($n) => $n % 2 === 1)
->map(fn($n) => $n * 10)
->all();
print_r($result);# A pipeline requires nothing of the value — any function that takes it will do:
result =
[1, 2, 3, 4, 5]
|> Enum.filter(fn number -> rem(number, 2) == 1 end)
|> Enum.map(fn number -> number * 10 end)
IO.inspect(result)That is the difference between an open and a closed set. To add a step to the fluent version you must edit the class or subclass it; the Elixir pipeline can pass through
Enum, String, a third-party library and a function you wrote five minutes ago, in any order, with no wrapper object and no ->all() at the end to unwrap. It also means there is nothing to construct: the value flowing through is a plain list the whole way, so any step can be tested on its own.Piping into a function that takes more arguments
The piped value always lands in the first argument slot, and every other argument is written normally in the parentheses. Operators can be piped into as well, by their module-qualified names.
<?php
$scores = [90, 45, 72, 88];
$passing = array_values(array_filter($scores, fn($score) => $score >= 60));
$total = array_sum($passing);
$report = implode(", ", $passing);
echo "{$report} (total {$total})\n";scores = [90, 45, 72, 88]
passing = Enum.filter(scores, fn score -> score >= 60 end)
report =
passing
|> Enum.join(", ") # piped value becomes Enum.join's FIRST argument
|> Kernel.<>(" (total ")
|> Kernel.<>(Integer.to_string(Enum.sum(passing)))
|> Kernel.<>(")")
IO.puts(report)The
Kernel.<> calls are here to make the mechanism visible, not because you should write them — in real code that last stretch would be one interpolated string. The rule they illustrate matters, though: a |> f(b, c) is exactly f(a, b, c), with no exceptions and no way to redirect the value into a different slot. When you need it elsewhere, stop piping and use an anonymous function: |> then(&String.replace(text, "x", &1)). Reaching for then often means the pipeline has ended and a named intermediate value would read better.Enum & Stream
array_map, array_filter, array_reduce
The three workhorses map straight across, into one module with one argument order. Three PHP annoyances disappear on the way.
<?php
$numbers = [1, 2, 3, 4, 5, 6];
$doubled = array_map(fn($n) => $n * 2, $numbers);
$evens = array_values(array_filter($numbers, fn($n) => $n % 2 === 0));
$total = array_reduce($numbers, fn($carry, $n) => $carry + $n, 0);
print_r($doubled);
print_r($evens);
echo $total, "\n";numbers = [1, 2, 3, 4, 5, 6]
doubled = Enum.map(numbers, fn number -> number * 2 end)
evens = Enum.filter(numbers, fn number -> rem(number, 2) == 0 end)
total = Enum.reduce(numbers, 0, fn number, accumulator -> accumulator + number end)
IO.inspect(doubled)
IO.inspect(evens)
IO.puts(total)First, argument order:
array_map takes the callback first and array_filter takes it second, an inconsistency every PHP developer has looked up. Every Enum function takes the collection first. Second, array_values: PHP's filter preserves the original keys, so removing element 0 leaves an array starting at key 1, which then serializes to a JSON object instead of an array — a bug so common it has its own Stack Overflow genre. Elixir lists have no keys, so nothing to renumber. Third, array_reduce puts the accumulator first in the callback while Elixir puts the element first — worth noting, since fn number, accumulator reads backwards to a PHP eye.foreach that produces a value
The PHP shape here — declare an empty array, loop, conditionally append — is the single most common loop in the language. A comprehension collapses it into one expression, and does the filtering with a pattern rather than an
if.<?php
$people = [
["name" => "Ada", "active" => true],
["name" => "Grace", "active" => false],
["name" => "Alan", "active" => true],
];
$names = [];
foreach ($people as $person) {
if ($person["active"]) {
$names[] = strtoupper($person["name"]);
}
}
print_r($names);people = [
%{name: "Ada", active: true},
%{name: "Grace", active: false},
%{name: "Alan", active: true}
]
names = for %{name: name, active: true} <- people, do: String.upcase(name)
IO.inspect(names)The quiet trick is that
<- in a comprehension is a filtering match: an element that does not fit the pattern is skipped rather than raising, which is the one place in Elixir where a failed match is not an error. So active: true in the pattern is doing the work of the if. You can also write the condition separately — for person <- people, person.active, do: … — and several generators nest like nested foreaches. Note that for is not a loop: there is no index, no break and no continue, because it builds a value rather than repeating a statement.Grouping and summarizing
Grouping is where PHP's single array type earns its keep —
$byCustomer[$key][] = $value creates the missing sub-array for you. Elixir will not auto-create anything, so grouping is a named function instead of an idiom.<?php
$orders = [
["customer" => "ada", "total" => 30],
["customer" => "grace", "total" => 45],
["customer" => "ada", "total" => 12],
];
$byCustomer = [];
foreach ($orders as $order) {
$byCustomer[$order["customer"]][] = $order["total"];
}
$totals = array_map("array_sum", $byCustomer);
print_r($totals);orders = [
%{customer: "ada", total: 30},
%{customer: "grace", total: 45},
%{customer: "ada", total: 12}
]
totals =
orders
|> Enum.group_by(& &1.customer, & &1.total)
|> Map.new(fn {customer, amounts} -> {customer, Enum.sum(amounts)} end)
IO.inspect(totals)Enum.group_by takes two functions: one that produces the key and one that produces the value to collect, so the second argument replaces the "pick the field" step you would otherwise do afterwards. Map.new then rebuilds the map with each list summed. The & &1.customer spelling is the capture shorthand from the Functions section — the space is required, since && is the boolean operator. The PHP version is shorter here, and honestly so; what the Elixir version buys is that each step is a value you can inspect on its own with IO.inspect in the middle of the pipe.Generators become Streams
PHP generators and Elixir streams solve the same problem — work through a sequence too large or too infinite to build in memory — but they are shaped very differently. A generator is a function you write with
yield; a stream is a value you compose out of existing pieces.<?php
function naturals(): Generator {
$value = 1;
while (true) {
yield $value++;
}
}
$result = [];
foreach (naturals() as $number) {
if ($number % 3 === 0) {
$result[] = $number * $number;
}
if (count($result) === 4) {
break;
}
}
print_r($result);result =
Stream.iterate(1, &(&1 + 1))
|> Stream.filter(fn number -> rem(number, 3) == 0 end)
|> Stream.map(fn number -> number * number end)
|> Enum.take(4)
IO.inspect(result)The rule that makes streams usable: everything in the
Stream module is lazy and returns another stream, and everything in Enum is eager and forces the work. Nothing above runs until Enum.take(4), and then exactly enough runs to produce four results. That composability is what PHP's generators lack — chaining a filter onto a generator means writing another generator that loops over the first, which is why the PHP version has to fold the filter, the map and the limit into one loop with a break. The cost is per-element overhead, so for a list you already hold in memory, plain Enum is faster; reach for Stream when the source is huge, infinite, or a file being read line by line.Control Flow
if produces a value, and there is no elseif
Elixir has
if and unless, and both return a value, so you assign the whole construct rather than assigning inside each branch. What Elixir does not have is elseif — a chain of conditions is cond.<?php
$temperature = 18;
if ($temperature > 25) {
$advice = "wear shorts";
} elseif ($temperature > 15) {
$advice = "a jacket will do";
} else {
$advice = "wear a coat";
}
echo $advice, "\n";temperature = 18
advice =
cond do
temperature > 25 -> "wear shorts"
temperature > 15 -> "a jacket will do"
true -> "wear a coat"
end
IO.puts(advice)Assigning the construct instead of assigning in each branch removes a whole error class: there is no path through this code where
advice ends up unset, because the expression must produce something. An if with no else returns nil when the condition is false, which is worth remembering before assigning one. And cond needs its final true -> catch-all — leaving it off raises CondClauseError rather than falling through to nil, which is the safer default even though it surprises people once.with — the happy path without early returns
This is the direct answer to "how do I write a guard clause without
return". with chains matches: each <- must succeed for the next to be tried, and the first one that fails skips straight to else with the offending value.<?php
function findUser(int $id): ?array {
return $id === 1 ? ["id" => 1, "email" => "ada@example.com"] : null;
}
function findSubscription(array $user): ?array {
return ["plan" => "pro", "user" => $user["email"]];
}
function summary(int $id): string {
$user = findUser($id);
if ($user === null) {
return "no such user";
}
$subscription = findSubscription($user);
if ($subscription === null) {
return "no subscription";
}
return "{$subscription["user"]} is on {$subscription["plan"]}";
}
echo summary(1), "\n";
echo summary(2), "\n";find_user = fn
1 -> {:ok, %{id: 1, email: "ada@example.com"}}
_ -> {:error, "no such user"}
end
find_subscription = fn user -> {:ok, %{plan: "pro", user: user.email}} end
summary = fn id ->
with {:ok, user} <- find_user.(id),
{:ok, subscription} <- find_subscription.(user) do
"#{subscription.user} is on #{subscription.plan}"
else
{:error, reason} -> reason
end
end
IO.puts(summary.(1))
IO.puts(summary.(2))Compare the two shapes. The PHP version interleaves the work with the checking, so the happy path is buried among the bail-outs; the
with block lists the steps that must all succeed, then states the result, and handles every failure in one place. The else is optional — without it, a failing step becomes the return value of the whole with, which is often exactly right when your caller already expects {:error, reason}. This is the construct you will reach for in a Phoenix controller, where four things in a row must go right before you can render.There is no while, and nothing to increment
A
while loop needs a variable it can change, and Elixir has none, so there is no while, no for (;;), no break and no continue. Repetition is recursion, and the loop variables become parameters.<?php
$total = 0;
$remaining = 1024;
while ($remaining > 1) {
$remaining = intdiv($remaining, 2);
$total++;
}
echo "halved {$total} times\n";defmodule Halving do
def count(1, halvings), do: halvings
def count(value, halvings) when value > 1, do: count(div(value, 2), halvings + 1)
end
IO.puts("halved #{Halving.count(1024, 0)} times")Read the recursion as the loop turned inside out: the
while condition becomes the when guard, the loop body becomes the arguments to the recursive call, and the base case becomes its own clause. This costs nothing at run time — a call in tail position (the last thing a function does) reuses the stack frame, so this runs in constant space exactly like the while. In practice you write this shape far less than you would expect, because Enum and Stream already cover almost every loop that walks a collection; hand-written recursion is for the cases they do not, like this one.Structs, Behaviours & Protocols
A class with only data becomes a struct
A struct is a map with a fixed set of keys, default values, and its module's name stamped on it. PHP 8's constructor property promotion produces almost the same declaration, so the two lines up nearly word for word.
<?php
class User {
public function __construct(
public string $name,
public string $email,
public bool $active = true,
) {}
}
$user = new User("Ada", "ada@example.com");
echo $user->name, "\n";
var_dump($user->active);defmodule User do
defstruct name: nil, email: nil, active: true
end
user = %User{name: "Ada", email: "ada@example.com"}
IO.puts(user.name)
IO.inspect(user.active)Two things differ, and both come from a struct being a map. The key set is closed:
%User{nickname: "A"} is a compile-time error, where a PHP object would need declare(strict_types=1) and a typed property to refuse it, and a plain array would accept anything. And there is no constructor to run, so there is nowhere to validate — the convention is a new/1 function in the module returning {:ok, user} or {:error, reason}, which is more honest than a constructor that can only signal failure by throwing. The hidden __struct__ key holds User, which is what lets a function head match %User{} specifically.Updating a struct returns a new one
A PHP method mutates
$this and returns nothing. An Elixir function takes the struct as its first argument and returns a new one — which is precisely what makes it pipeable.<?php
class Counter {
public function __construct(public int $count = 0) {}
public function increment(): void {
$this->count++; // mutates the object in place
}
}
$counter = new Counter();
$counter->increment();
$counter->increment();
echo $counter->count, "\n";defmodule Counter do
defstruct count: 0
def increment(%Counter{count: count} = counter), do: %{counter | count: count + 1}
end
counter = %Counter{}
counter = counter |> Counter.increment() |> Counter.increment()
IO.puts(counter.count)The two rebindings of
counter are the price, and forgetting one is the most common beginner bug: Counter.increment(counter) on a line by itself computes a new counter and throws it away, with no warning that anything is wrong. The compensation is that there is no aliasing to reason about — if you handed counter to three other functions before this line, none of them saw it change. The %Counter{count: count} = counter head is doing two jobs at once: asserting the argument really is a Counter and pulling out the field, so a map that merely looks similar is rejected.An interface becomes a behaviour
A behaviour is Elixir's interface: a module declares the functions an implementer must provide, and an implementing module says
@behaviour and gets a compiler warning if it misses one. The difference is what gets passed around.<?php
interface Notifier {
public function send(string $message): string;
}
class EmailNotifier implements Notifier {
public function send(string $message): string {
return "email: {$message}";
}
}
function notify(Notifier $notifier, string $message): void {
echo $notifier->send($message), "\n";
}
notify(new EmailNotifier(), "deploy finished");defmodule Notifier do
@callback send(String.t()) :: String.t()
end
defmodule EmailNotifier do
@behaviour Notifier
@impl Notifier
def send(message), do: "email: #{message}"
end
notify = fn notifier, message -> IO.puts(notifier.send(message)) end
notify.(EmailNotifier, "deploy finished")PHP passes an instance, and the interface constrains its type. Elixir passes the module itself —
EmailNotifier is a value, an atom, and notifier.send(message) calls the function on whatever module the variable holds. That is why configuration in Elixir so often reads config :app, :notifier, EmailNotifier: swapping an implementation for a test means passing a different module name, with no container, no binding and no interface-to-implementation registry. The @impl Notifier annotation is optional and worth writing; it makes the compiler check that this function is actually part of the behaviour, catching a typo that would otherwise define an unrelated function silently.Protocols — adding behavior to types you do not own
A protocol is dispatch chosen by the type of the first argument — Elixir's answer to an interface you can implement for a type you did not write.
String.Chars is the one behind string interpolation, so implementing it is the equivalent of PHP's __toString.<?php
// PHP can only do this if it controls the class:
class Money implements Stringable {
public function __construct(private int $cents) {}
public function __toString(): string {
return sprintf("$%.2f", $this->cents / 100);
}
}
echo new Money(1250), "\n";defmodule Money do
defstruct cents: 0
end
defimpl String.Chars, for: Money do
def to_string(%Money{cents: cents}) do
"$" <> :erlang.float_to_binary(cents / 100, decimals: 2)
end
end
IO.puts(to_string(%Money{cents: 1250}))The part PHP cannot do is the
for:. A defimpl can name any type, including one from a library or a built-in like Integer, and it lives in your code rather than theirs — so you can teach someone else's struct how to serialize into your format without a wrapper class or a subclass. That is the same capability Rust calls a trait implementation and PHP has no equivalent for at all: adding an interface to a class you do not control requires editing that class.Error Handling
Exceptions exist, and are not the normal way
The machinery is all here and maps directly:
try is try, catch is rescue, and finally is after. What differs is how often you will write it.<?php
try {
$result = intdiv(10, 0);
echo $result, "\n";
} catch (DivisionByZeroError $error) {
echo "caught: ", $error->getMessage(), "\n";
} finally {
echo "cleanup runs either way\n";
}try do
result = div(10, 0)
IO.puts(result)
rescue
error in ArithmeticError -> IO.puts("caught: #{Exception.message(error)}")
after
IO.puts("cleanup runs either way")
endAlmost never, is the answer. In PHP an exception is the standard way to report that something went wrong, so a service method throws and a controller catches. In Elixir the standard way is the
{:ok, value} / {:error, reason} tuple from the Atoms section, and an exception is reserved for something genuinely unexpected — a bug, a violated invariant, a broken assumption. The reason is not taste: an exception is a non-local jump that skips whatever the caller was doing, and the BEAM has a better answer for real failure, which is to let the process die and be restarted by its supervisor. That is the next few rows.The error tuple as the normal return
Here is the same validation written the idiomatic way in each language: PHP throws and the caller wraps the call in
try; Elixir returns a tuple and the caller matches on it.<?php
function parseAge(string $input): int {
if (!ctype_digit($input)) {
throw new InvalidArgumentException("not a number: {$input}");
}
return (int) $input;
}
foreach (["36", "abc"] as $input) {
try {
echo parseAge($input), "\n";
} catch (InvalidArgumentException $error) {
echo "rejected: ", $error->getMessage(), "\n";
}
}parse_age = fn input ->
case Integer.parse(input) do
{age, ""} -> {:ok, age}
_ -> {:error, "not a number: #{input}"}
end
end
for input <- ["36", "abc"] do
case parse_age.(input) do
{:ok, age} -> IO.puts(age)
{:error, reason} -> IO.puts("rejected: #{reason}")
end
endThe advantage is that the failure is in the type of the return value, so it is impossible to ignore by accident — a
case that omits the {:error, _} branch raises at that line rather than letting an exception unwind three frames to somewhere that has lost the context. Note {age, ""}: Integer.parse returns the number and the unparsed remainder, so matching the remainder against the empty string is what rejects "36abc" — a distinction PHP's (int) cast quietly loses, since (int) "36abc" is 36.The ! convention — two versions of the same function
Elixir libraries ship both behaviors as two functions with the same name, the raising one marked with a trailing
! — Map.fetch and Map.fetch!, File.read and File.read!. PHP has no such convention, so each library chose once and you adapt.<?php
// PHP libraries pick one policy and you live with it:
$decoded = json_decode("{bad", true);
var_dump($decoded); // null, silently
// PHP 7.3+ can opt into throwing, per call:
try {
json_decode("{bad", true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $error) {
echo "threw: ", $error->getMessage(), "\n";
}# The plain version returns a tuple:
IO.inspect(Integer.parse("abc"))
# The ! version raises instead, for when a failure is a bug:
try do
Integer.to_string(:not_an_integer)
rescue
error -> IO.puts("raised: #{Exception.message(error)}")
end
IO.inspect(Map.fetch(%{a: 1}, :b))
try do
Map.fetch!(%{a: 1}, :b)
rescue
error in KeyError -> IO.puts("raised: #{Exception.message(error)}")
endChoosing between them is a judgment about whose fault a failure is. If the input came from a user or a network and failing is a normal outcome, take the tuple and handle it. If the value is one your own code produced and a failure means you have a bug, take the
! version — an immediate crash with a precise message beats a nil that travels. That maps neatly onto JSON_THROW_ON_ERROR, which exists for the same reason; the difference is that Elixir makes the choice visible in the function name at every call site rather than in a flag argument.Processes & Concurrency
What happens between two requests
This is the difference the whole page has been building towards, and it is architectural rather than syntactic. A PHP process is created for a request, serves it, and is destroyed; nothing in memory survives, which is why every PHP application reaches for Redis or the database to remember anything at all.
<?php
// A static keeps its value between CALLS, which looks like what we want:
function countVisit(): int {
static $visits = 0;
return ++$visits;
}
countVisit();
echo "count is ", countVisit(), "\n";
// ...but the process ends with the response, so the next request starts at 1.# The VM keeps running, so a value can outlive the work that produced it:
{:ok, agent} = Agent.start_link(fn -> 0 end)
Agent.update(agent, fn count -> count + 1 end)
Agent.update(agent, fn count -> count + 1 end)
IO.puts("count is #{Agent.get(agent, & &1)}")
IO.puts("and it is still there for the next request")The BEAM starts once and keeps running, so an Elixir application has a memory. An
Agent is the simplest way to use it: a process holding one value, updated by sending it a function. The counter above genuinely counts across requests, and a cache, a rate limiter or a connection pool needs no external service. The trade is that you now have shared mutable state, which is the thing PHP's design eliminates by force — and the next rows are how the BEAM makes it safe without locks: the state lives inside a process, and the only way to touch it is to send that process a message.Processes, and the mailbox
A BEAM process is not an operating-system process or a thread. It is a few hundred bytes of VM bookkeeping, it starts in microseconds, and a single machine runs millions of them — which is why the answer to "how do I do this concurrently" in Elixir is almost always "spawn a process", where in PHP the honest answer is a queue worker.
<?php
// PHP has no lightweight processes. The nearest thing needs pcntl
// (absent from most builds), and each fork is a real OS process:
$message = "ping";
$reply = strtoupper($message); // so this stays sequential
echo "got {$reply}\n";parent = self()
spawn(fn ->
receive do
{:ping, from} -> send(from, {:pong, "PING"})
end
end)
|> send({:ping, parent})
receive do
{:pong, reply} -> IO.puts("got #{reply}")
after
1_000 -> IO.puts("timed out")
endThree primitives are the whole model.
spawn starts a process and returns its identifier. send puts a message in that process's mailbox and returns immediately, never blocking. receive takes the first message matching one of its patterns, waiting if none has arrived — with after as the timeout, which you should almost always include. Note there is nothing shared: each process has its own heap, the message is copied, and so no lock, mutex or race condition is possible by construction. That is the trade the BEAM makes — copying costs memory bandwidth, and buys away the entire category of bug PHP avoids by throwing the world away instead.Doing several things at once
Fanning out work is the thing PHP genuinely cannot do in-process. Five HTTP calls in a PHP request happen one after another unless you reach for curl_multi or an async extension; on the BEAM they are five processes and the total is the slowest one.
<?php
function slowSquare(int $number): int {
usleep(1000); // stand-in for a network call
return $number * $number;
}
$squares = [];
foreach ([1, 2, 3, 4, 5] as $number) {
$squares[$number] = slowSquare($number);
}
print_r($squares);
echo "one after another\n";parent = self()
for number <- [1, 2, 3, 4, 5] do
spawn(fn -> send(parent, {number, number * number}) end)
end
results =
for _ <- 1..5, into: %{} do
receive do
{number, square} -> {number, square}
end
end
IO.inspect(results)
IO.puts("all five at the same time")Real code would write this as
Task.async_stream, which is one line and handles timeouts, failures and back-pressure; it is spelled out here with spawn and receive because seeing the mailbox once makes Task obvious afterwards. Note that results arrive in completion order, not the order they were spawned, which is why they are collected into a map keyed by the input rather than a list. That into: %{} option on the comprehension is what builds a map instead of a list.Let it crash — a failure is contained
PHP already has a strong isolation story, and it is worth naming: a fatal error kills one request and nothing else, because each request is its own process. The BEAM gives you the same containment at a much finer grain — per task, not per request.
<?php
// A fatal error in PHP ends the request. The other requests in
// other processes are unaffected — this is PHP's isolation model:
try {
throw new RuntimeException("worker failed");
} catch (RuntimeException $error) {
echo "the request handles it: ", $error->getMessage(), "\n";
}
echo "the parent script continues\n";parent = self()
spawn(fn ->
send(parent, :starting)
raise "worker failed"
end)
receive do
:starting -> IO.puts("the worker started and then died")
after
1_000 -> IO.puts("timed out")
end
Process.sleep(50)
IO.puts("the parent process continues, untouched")The spawned process raised and died, printing a crash report, and the parent did not notice. That is the default: processes are unlinked, so a failure is contained to whatever was doing the failing. "Let it crash" follows from it — instead of defensively rescuing every possible error, you let a process die on bad input and have something restart it in a known-good state, because a fresh process has no corrupted leftovers to carry forward. The something is a supervisor, which watches a set of processes and restarts them by a declared strategy. This is what people mean by BEAM fault tolerance, and it has no PHP equivalent because there is nothing long-lived to supervise.
State That Outlives a Request
A GenServer is the object PHP could not keep
A GenServer is a process that holds state and answers messages, wrapped in a standard shape so that supervision, timeouts, tracing and hot code upgrades all work on it for free. It is the closest thing Elixir has to a long-lived object — with one enormous difference.
<?php
class Basket {
private array $items = [];
public function add(string $item): void { $this->items[] = $item; }
public function contents(): array { return $this->items; }
}
// Alive for one request only. To survive, it must be serialized somewhere.
$basket = new Basket();
$basket->add("book");
$basket->add("pen");
print_r($basket->contents());defmodule Basket do
use GenServer
def start_link(_), do: GenServer.start_link(__MODULE__, [])
def add(basket, item), do: GenServer.cast(basket, {:add, item})
def contents(basket), do: GenServer.call(basket, :contents)
@impl GenServer
def init(items), do: {:ok, items}
@impl GenServer
def handle_cast({:add, item}, items), do: {:noreply, items ++ [item]}
@impl GenServer
def handle_call(:contents, _from, items), do: {:reply, items, items}
end
{:ok, basket} = Basket.start_link(nil)
Basket.add(basket, "book")
Basket.add(basket, "pen")
IO.inspect(Basket.contents(basket))The difference is that only the GenServer's own process touches its state. Every caller sends a message and waits, so requests are serialized by construction: two callers adding to this basket simultaneously cannot interleave, because the process handles one message at a time. A PHP object shared between concurrent requests would need a lock, which is precisely why PHP does not share objects between requests at all. Note the two call styles —
call waits for a reply and is synchronous, cast is fire-and-forget — and that the public functions and the callbacks live in the same module, which is the convention: the top half is the API a caller sees, the bottom half is what runs inside the process.What a GenServer is underneath
A GenServer is not magic, and seeing the loop it hides makes the whole model click. A process that wants to remember something calls itself recursively, passing the new state as an argument, and blocks in
receive between messages.<?php
// The equivalent loop in PHP would be a daemon script:
// while (true) { $message = $queue->pop(); ... }
// which is a different program from the one serving requests.
$state = 0;
foreach ([["add", 5], ["add", 7]] as [$operation, $value]) {
$state = match ($operation) {
"add" => $state + $value,
};
}
echo "final state {$state}\n";loop = fn loop, state ->
receive do
{:add, value} -> loop.(loop, state + value)
{:get, from} -> send(from, state)
end
end
counter = spawn(fn -> loop.(loop, 0) end)
send(counter, {:add, 5})
send(counter, {:add, 7})
send(counter, {:get, self()})
receive do
total -> IO.puts("final state #{total}")
after
1_000 -> IO.puts("timed out")
endThat is the entire mechanism: state is a parameter of a function that never returns, so it is mutable-looking without any mutation, and it is private because the only way in is the mailbox. Everything
use GenServer adds is the boilerplate around this — replying to the caller, handling system messages, reporting to a supervisor. Write the loop by hand once for understanding and then use GenServer, because the hand-rolled version silently lacks all of that. The loop.(loop, state) spelling is how an anonymous function calls itself: it has no name to refer to, so it is handed itself as its first argument — a named module function would simply say loop(state).A supervisor restarts what dies
Supervision is the part of OTP with no PHP counterpart inside the language. A supervisor is a process whose only job is to start a set of child processes and restart them when they die, according to a declared strategy.
<?php
// The nearest thing PHP has INSIDE the language is a retry you write:
function flakyWorker(int $attempt): int {
if ($attempt < 2) {
throw new RuntimeException("worker died");
}
return 1;
}
$count = 0;
foreach ([1, 2] as $attempt) {
try {
$count = flakyWorker($attempt);
} catch (RuntimeException $error) {
echo "restarting after: ", $error->getMessage(), "\n";
}
}
echo $count, "\n";defmodule Counter do
use Agent
def start_link(_), do: Agent.start_link(fn -> 0 end, name: __MODULE__)
end
{:ok, supervisor} = Supervisor.start_link([Counter], strategy: :one_for_one)
IO.inspect(Agent.get(Counter, & &1))
Agent.update(Counter, fn count -> count + 1 end)
IO.inspect(Agent.get(Counter, & &1))
IO.inspect(Process.alive?(supervisor))The PHP column shows what you would otherwise hand-write: a retry loop that has to know which failures are retryable and how to rebuild whatever the dead attempt left behind. Real PHP supervision lives one level up instead — supervisord, systemd or Laravel Horizon restarting a worker process — which means the unit of failure is the whole program and recovery costs a full start-up. A BEAM supervisor restarts one child in microseconds while everything else keeps serving, and the strategies say how far the blast reaches:
:one_for_one restarts only what died, :one_for_all restarts all the children when any dies (for children that only make sense together), and :rest_for_one restarts the ones started after it. A real application is a tree of these, and that tree is the architecture document.⚠ Gotchas for PHP Developers
Nothing is ever coerced
PHP has two equality operators because it has type juggling, and the rules changed materially in PHP 8. Elixir also has two, but they mean something different, and the reason will surprise you.
<?php
var_dump("1" == 1); // true — PHP juggles
var_dump("1" === 1); // false — strict comparison
var_dump(0 == ""); // false in PHP 8 (was true in PHP 7!)
var_dump("abc" == 0); // false in PHP 8 (was true in PHP 7!)IO.inspect("1" == 1) # false — no juggling anywhere
IO.inspect(1 == 1.0) # true — numeric comparison across int and float
IO.inspect(1 === 1.0) # false — === also compares the TYPE
IO.inspect(1 < "abc") # true — total ordering across all typesElixir's
== never coerces; the only latitude it allows is comparing an integer with a float numerically, and === is the stricter one that also demands the same type. The genuinely strange line is the last: any two values can be compared with <, ordered by type — number < atom < reference < function < port < pid < tuple < map < list < bitstring. It never raises, which is what makes Enum.sort work on a mixed list, and it is also a trap: a comparison bug that PHP would surface as a TypeError silently returns a boolean here. Erlang chose it so that sorting is always total; know it exists and do not lean on it.Why a list of numbers printed as a word
This one used to ambush every newcomer, and you may still meet it in older code, in Erlang output, or in a library that returns charlists. A list of small integers is a charlist, so some tools print it as text.
<?php
$bytes = [104, 105];
print_r($bytes); // prints the array
echo implode("", array_map("chr", $bytes)), "\n"; // you must ask for "hi"bytes = [104, 105]
IO.inspect(bytes) # [104, 105] in modern Elixir
IO.inspect(bytes, charlists: :as_lists) # explicit, for older tooling
IO.puts(List.to_string(bytes)) # "hi"Elixir 1.14 changed the default so
IO.inspect prints [104, 105] and only shows the charlist form when asked, which removed most of the confusion. The underlying fact has not changed and cannot: there is no separate charlist type, so [104, 105] and ~c"hi" are the same value. If a function hands you what looks like nonsense text where you expected numbers, it is this — and List.to_string/1 and String.to_charlist/1 convert in each direction.Division always produces a float
PHP's
/ returns an integer when the division is exact and a float otherwise, so the return type depends on the values. Elixir's / always returns a float.<?php
var_dump(10 / 4); // float(2.5)
var_dump(10 / 5); // int(2) — PHP narrows when it divides evenly
var_dump(intdiv(10, 4)); // int(2)
var_dump(10 % 4); // int(2)IO.inspect(10 / 4) # 2.5
IO.inspect(10 / 5) # 2.0 — ALWAYS a float, even when exact
IO.inspect(div(10, 4)) # 2
IO.inspect(rem(10, 4)) # 2The consequence bites when a result is used as an index, a count, or an argument to something expecting an integer:
10 / 5 gives 2.0, and Enum.take(list, 2.0) raises. Use div/2 whenever you want integer division, which is PHP's intdiv. Also note that % is not the modulo operator here — it introduces a map — and the operator you want is the function rem/2. Like PHP's %, it takes the sign of the dividend; Integer.mod/2 is the one that takes the sign of the divisor.A computed value that nobody keeps is simply lost
PHP's array functions are split between those that return a new array and those that modify in place and return a boolean —
sort, shuffle and array_push are all the second kind. Every Elixir function is the first kind, without exception.<?php
$names = ["ada", "grace"];
sort($names); // sorts IN PLACE — the return value is a bool
print_r($names);
$more = ["alan"];
array_push($more, "edsger"); // also in place
print_r($more);names = ["ada", "grace"]
Enum.sort(names) # computed, and thrown away
IO.inspect(names) # unchanged
sorted = Enum.sort(names) # you must keep the result
IO.inspect(sorted)This produces the most common beginner bug in the language, and it fails silently: a line that calls a function and ignores its result does nothing at all. The compiler does warn for some cases, but not once the call is inside a pipeline or passed along. The habit to build is that any line which is not
IO.something should either bind its result or be piped into the next step — if it does neither, it is dead code. The compensation is that you never have to remember which functions mutate, because none of them do.A block cannot change the outside world
PHP's loop body shares the enclosing scope, so accumulating into a variable declared outside is the normal way to write a total. In Elixir, a binding made inside a block is not visible after it — and the outer
total is untouched.<?php
$total = 0;
foreach ([1, 2, 3] as $number) {
$total += $number; // reaches out and modifies $total
}
echo $total, "\n";total = 0
for number <- [1, 2, 3] do
total = total + number # rebinds a COPY inside the block
end
IO.puts(total) # still 0
IO.puts(Enum.sum([1, 2, 3])) # what you actually wantedRead the output:
0, not 6. The compiler warns about it, and it is the moment most people finally understand that immutability is not a suggestion. The answer is never to find a way to reach out of the block; it is to use a function that returns the accumulated value — Enum.sum here, or Enum.reduce when the accumulation is not a standard one. If you genuinely need a value that several pieces of code can change over time, that is what a process is for, which is the State section above.