Output & Running
Hello, World
Erlang prints with
io:format, and the newline is ~n rather than \n — it is a directive inside a format string, not an escape inside a literal.<?php
echo "Hello, World!\n";io:format("Hello, World!~n").The
module:function(Args) shape is how every Erlang call to another module is written, so io:format is the format function of the io module, and the colon is not a namespace separator in the PHP sense — it is part of the call. The trailing period ends the expression, which matters far more than it looks; the next section is about that punctuation.The format directives you will actually see
Every argument goes in a list after the format string, which is the first thing that trips a PHP reader —
io:format("~p~n", Value) without the brackets is a common beginner error and a confusing one.<?php
$person = ["name" => "Ada", "age" => 36];
printf("%s is %d\n", $person["name"], $person["age"]);
print_r($person);
printf("%.2f\n", 3.14159);Person = #{name => "Ada", age => 36},
io:format("~s is ~p~n", [maps:get(name, Person), maps:get(age, Person)]),
io:format("~p~n", [Person]),
io:format("~.2f~n", [3.14159]).Four directives cover most real code.
~p is "pretty print" and renders any term for a human, which is print_r and var_dump at once and needs no declaration for a custom type. ~s prints a string or iodata as characters. ~w is the raw, unformatted version of ~p, and you will see it in log lines where the formatting cost matters. ~n is the newline. The one to be careful with is ~s: hand it something that is not a string or iodata and you get a badarg, where ~p would have printed it happily.The shell is where you will actually work
Both languages have an interactive shell, and the difference is how much it matters.
php -a is a curiosity; erl is where an Erlang operator spends much of an incident.<?php
// php -a gives you an interactive shell most PHP developers
// touch about once a year.
echo 1 + 1, "\n";% erl starts a shell. In a running release it is remsh, attached
% to the live node — which is how Erlang systems are diagnosed.
io:format("~p~n", [1 + 1]).The reason is that the shell can attach to a running production node —
erl -remsh opens a prompt inside the live system, where you can inspect a process's state, trace a function's calls with dbg, and count messages in a mailbox, all without a restart. PHP structurally cannot offer this: by the time you would look, the process that served the request has exited. It is also why the examples in this column are shell expressions rather than module files — comma between expressions, single period at the end — which is exactly what you would type at that prompt.Reading a .erl File
Comma, semicolon and period each mean something
This is the single biggest obstacle to reading Erlang, and it is pure punctuation. The three marks are not interchangeable and not optional: comma separates expressions in a sequence, semicolon separates alternative clauses, and period ends a top-level form.
<?php
function classify(int $score): string {
if ($score >= 90) {
return "excellent";
} else {
return "ordinary";
}
}
echo classify(95), " ", classify(50), "\n";Classify = fun(Score) ->
if
Score >= 90 -> "excellent"; % ; separates CLAUSES
true -> "ordinary" % no ; before end
end
end, % , separates EXPRESSIONS
io:format("~s ~s~n", [Classify(95), Classify(50)]).
% ^ . ends the whole thingRead them as English and they stop being arbitrary: comma is "and then", semicolon is "or else", and period is "that is the end of this sentence". The rule that catches everyone is that the last clause before an
end takes no semicolon, exactly as the last item in a list takes no comma. PHP uses ; for what Erlang uses , for, which is why a PHP reader's instinct is wrong in the most common position. When you see a function definition ending in . you have reached the end of that function and the next line starts a new one.Capitalization is syntax, not style
In Erlang the first letter of a bare word decides what it is. Uppercase means a variable. Lowercase means an atom — a constant whose name is its own value, needing no declaration anywhere.
<?php
$userName = "Ada"; // a variable — the $ says so
$status = "active"; // a string, distinguished by quotes
echo "{$userName} is {$status}\n";UserName = "Ada", % Uppercase first letter = a VARIABLE
Status = active, % lowercase, unquoted = an ATOM
io:format("~s is ~p~n", [UserName, Status]).PHP settles the same question with
$, so a PHP reader has nothing to unlearn except where to look. Two consequences worth carrying. A lowercase word you do not recognize is almost certainly an atom rather than an undefined constant, which is why Erlang code is full of bare words like ok, error, undefined and infinity — they are values, not names of values. And an underscore-prefixed variable such as _Reason is a real variable the compiler will not warn about being unused, while a bare _ matches anything and binds nothing. You will see both constantly in OTP callbacks.The header at the top of every file
The top of a
.erl file is a short list of attributes, and two of them carry all the information a PHP reader wants: which module this is, and what is public.<?php
declare(strict_types=1);
namespace App\Billing;
final class Counter
{
public static function start(): int { return 0; }
private static function step(int $n): int { return $n + 1; }
}%%% A file called counter.erl, module counter.
-module(counter).
%% Only these are callable from outside. Note NAME/ARITY.
-export([start/0, step/1]).
start() -> 0.
step(N) -> N + 1.
%% Anything not exported is private, with no keyword saying so.The module name must match the filename, so
counter.erl declares -module(counter) and nothing else — there is no namespace hierarchy at all, and every module in a running system shares one flat namespace. That is why OTP libraries prefix their modules by hand: gen_server, ssl_connection, rabbit_channel. Visibility has no keyword: a function is public if and only if it appears in -export, so the export list is the file's public interface and reading it first is the fastest way into an unfamiliar module. The comment convention is worth knowing too — %%% for the file, %% for a function, % for a trailing note.A -spec is documentation the compiler ignores
A
-spec looks like a type declaration and behaves like a docblock. The compiler does not check it; Dialyzer, a separate optional tool, reads it during a separate optional run.<?php
/**
* @param int $value
* @return int
*/
function double(int $value): int {
return $value * 2;
}
echo double(21), "\n";%% -spec double(integer()) -> integer().
%% A spec is optional, sits above the function, and is read by
%% Dialyzer rather than by the compiler.
Double = fun(X) -> X * 2 end,
io:format("~p~n", [Double(21)]).That places it between PHP's two mechanisms. PHP 8's
int $value is checked at run time and will throw; PHP's @param docblock is checked by PHPStan or Psalm if you run them. An Erlang -spec is the second kind — and Dialyzer's design goal is no false positives, meaning it reports only discrepancies it can prove, and stays silent about everything it cannot. So a clean Dialyzer run is not a statement that the code is type-correct, and a -spec you read in unfamiliar source is the author's intent rather than a guarantee. Treat it as excellent documentation, which is genuinely what it is.Variables & the Match Operator
A variable is bound once, and that is enforced
Erlang has no assignment. A variable is bound once and cannot be rebound, which is why real Erlang code is full of names like
State0, State1, State2 threading through a function.<?php
$count = 1;
$count = $count + 1; // ordinary PHP
echo $count, "\n";Count = 1,
% Count = 2 here would be a badmatch — the name is already bound.
NextCount = Count + 1,
io:format("~p ~p~n", [Count, NextCount]).Read the two outputs: the PHP column can only print
2, because the 1 is gone, while the Erlang column still has both. Those numbered names are not a style someone chose — they are forced, and recognizing them is part of reading Erlang fluently: State1 is the state after step one. A PHP developer should read a numbered sequence as "this is the pipeline of transformations", which is what a chain of reassignments would be. Elixir relaxed this rule and allows rebinding, which is the single most visible syntactic difference between the two languages and the reason Elixir code has no numbered variables in it.= is a match, not an assignment
This is the idea that makes Erlang read strangely at first.
= is the match operator: it takes a pattern on the left and a value on the right, binds any unbound variables, and fails with badmatch if the shapes do not agree.<?php
$point = [3, 4];
// PHP has destructuring, but = is still assignment:
[$x, $y] = $point;
echo "x={$x} y={$y}\n";
// Asserting is a separate operation:
var_dump($x === 3);Point = {3, 4},
% The SAME operator binds and asserts:
{X, Y} = Point,
io:format("x=~p y=~p~n", [X, Y]),
% A literal in the pattern is an executable claim:
{3, 4} = Point,
io:format("the assertion held~n").So
{X, Y} = Point destructures like PHP's [$x, $y] = $point and asserts that Point really is a two-element tuple. When you meet {ok, Result} = some:call() in unfamiliar code — and you will, constantly — read it as "this must succeed, and I want the value": the line is both the unpacking and the assertion that the call returned ok. If it returned {error, Reason} the process crashes right there, deliberately, which is the subject of the Errors section.case matches shapes, not values
PHP 8's
match compares a subject against values. Erlang's case compares against patterns, so a branch can take the value apart at the same moment it decides which branch applies.<?php
$results = [
["ok", 42],
["error", "timed out"],
];
foreach ($results as $result) {
echo match ($result[0]) {
"ok" => "got {$result[1]}",
"error" => "failed: {$result[1]}",
}, "\n";
}Describe = fun(Result) ->
case Result of
{ok, Value} -> io_lib:format("got ~p", [Value]);
{error, Reason} -> io_lib:format("failed: ~s", [Reason])
end
end,
io:format("~s~n~s~n", [Describe({ok, 42}), Describe({error, "timed out"})]).Notice that the PHP version has to index back into
$result inside each arm, because matching on $result[0] discarded the connection to $result[1]. The Erlang branch binds Value and Reason as part of choosing. Both languages raise when nothing matches — UnhandledMatchError and case_clause — which is the good behavior. Note io_lib:format rather than io:format: it returns the formatted iodata instead of printing it, which is the pair you want whenever a value is being built rather than emitted.The Data You Will Meet
The tagged tuple is everywhere
A tuple is a fixed-size ordered container in braces, and combined with atoms it produces the convention behind almost every Erlang API you will read:
{ok, Value} or {error, Reason}.<?php
// PHP returns a bare value and signals failure out of band:
$decoded = json_decode('{"name": "Ada"}', true);
if ($decoded === null) {
echo "failed: ", json_last_error_msg(), "\n";
} else {
echo $decoded["name"], "\n";
}% Erlang returns a tagged tuple; success and failure have the same shape:
Decoded = {ok, #{name => "Ada"}},
case Decoded of
{ok, Person} -> io:format("~s~n", [maps:get(name, Person)]);
{error, Reason} -> io:format("failed: ~p~n", [Reason])
end.Recognizing it is most of what it takes to read an unfamiliar OTP module. Compare what PHP has to do here:
json_decode returns null on failure, but null is also the correct decoding of the input "null", so the two are indistinguishable without json_last_error(). A tagged tuple has no such ambiguity. You will also meet the one-element form ok on its own, for a call that either succeeds with nothing to report or returns {error, Reason} — file:close/1 is the canonical example.Lists, and the cons pattern
An Erlang list is a singly linked list, and the vertical bar is the cons operator — used once as a pattern to take a list apart and once as a constructor to put one together.
<?php
$numbers = [1, 2, 3, 4];
$first = array_shift($numbers); // mutates $numbers
echo $first, "\n";
print_r($numbers);
array_unshift($numbers, 0);
print_r($numbers);Numbers = [1, 2, 3, 4],
[First | Rest] = Numbers, % destructures; nothing is mutated
io:format("~p~n", [First]),
io:format("~p~n", [Rest]),
WithZero = [0 | Numbers], % a new list sharing the old one's tail
io:format("~p~n", [WithZero]).That symmetry is the shape most Erlang recursion is written in, so
[H|T] in unfamiliar code reads as "the first element and everything else". Prepending is free and WithZero genuinely shares its tail with Numbers in memory, which is safe precisely because nothing can be modified. There is no cheap indexing — lists:nth(3, L) walks — and no cheap append, which is why idiomatic code builds lists backwards and calls lists:reverse/1 once at the end. If you see that pattern, it is not a bug.Maps, and the proplists you will still meet
Maps are the modern answer and arrived in OTP 17, which is recent enough that a large amount of production Erlang predates them. Both forms are worth recognizing.
<?php
$person = ["name" => "Ada", "age" => 36];
echo $person["name"], "\n";
$older = $person;
$older["age"] = 37;
echo $older["age"], "\n";
var_dump(array_key_exists("email", $person));Person = #{name => "Ada", age => 36},
io:format("~s~n", [maps:get(name, Person)]),
Older = Person#{age := 37}, % := UPDATES; => would also insert
io:format("~p~n", [maps:get(age, Older)]),
io:format("~p~n", [maps:is_key(email, Person)]),
% Older code uses a proplist — a list of {Key, Value} tuples:
Options = [{timeout, 5000}, {retries, 3}],
io:format("~p~n", [proplists:get_value(timeout, Options)]).The map update syntax has a distinction PHP does not:
:= requires the key to exist already and fails otherwise, while => inserts or updates. Using := where you mean "change this field" is a free typo check, and seeing it tells you the author expected the key to be there. A proplist is just a list of two-element tuples, which is why it keeps insertion order and permits duplicates — proplists:get_value/2 returns the first match. It is the older options-passing convention and is still everywhere in OTP's own APIs, so you will read plenty of it.Records are a preprocessor trick
A record looks like a struct and is really a tuple with named positions, expanded by the preprocessor before the compiler sees it. That single fact explains every strange thing about it.
<?php
class Person {
public function __construct(
public string $name,
public int $age,
) {}
}
$person = new Person("Ada", 36);
echo $person->name, "\n";
$older = new Person($person->name, 37);
echo $older->age, "\n";%% In a module file (records cannot exist at expression level):
-record(person, {name, age}).
start() ->
Person = #person{name = "Ada", age = 36},
io:format("~s~n", [Person#person.name]),
%% Update syntax returns a NEW record:
Older = Person#person{age = 37},
io:format("~p~n", [Older#person.age]).The expansion means
#person{name = "Ada", age = 36} becomes the tuple {person, "Ada", 36}, so the field names exist only at compile time and only in files that have the -record declaration in scope — which is why record definitions live in .hrl header files that get -included, and why changing a record's shape means recompiling every module that uses it. It also means a record printed with ~p shows as a bare tuple with no field names at all, which is genuinely confusing the first time. Maps were added partly to escape all of this, but OTP's own internals are full of records and you will read them.Three Kinds of String
A double-quoted string is a list of numbers
This is the one that ambushes every newcomer. There is no string type in Erlang.
"abc" is a list of integers, and everything that works on lists works on it.<?php
$text = "abc";
var_dump(strlen($text));
var_dump($text[0]);
var_dump(is_string($text));Text = "abc", % a LIST of code points
io:format("~p~n", [is_list(Text)]),
io:format("~p~n", [length(Text)]),
io:format("~p~n", [hd(Text)]), % 97, the code point for $a
io:format("~p~n", [Text]). % prints "abc" — see the noteThe confusing part is the printing.
~p shows a list of small integers as text when every element happens to be a printable code point, so [97, 98, 99] displays as "abc" — and a list of numbers that just happens to contain 72 and 105 displays as "Hi", which has sent many people hunting for a bug that does not exist. If a value you expected to be numbers prints as a word, this is why. Use io:format("~w~n", [Text]) to see the raw list.Binaries and iodata — what real code actually uses
Production Erlang rarely uses double-quoted strings for real text. It uses binaries, written
<<"…">>, which are contiguous bytes rather than a linked list of integers — and it passes around iodata, which is a nested list of them.<?php
$name = "Ada";
$role = "Founder";
// PHP concatenates, copying as it goes:
$line = "<b>" . $name . "</b> - " . $role;
echo $line, "\n";
echo strlen($name), "\n";
echo strlen($line), "\n";Name = <<"Ada">>, % a BINARY: contiguous bytes
Role = <<"Founder">>,
% iodata: a nested list, never flattened until it is written
Line = [<<"<b>">>, Name, <<"</b> - ">>, Role],
io:format("~s~n", [Line]),
io:format("~p~n", [byte_size(Name)]),
io:format("~p~n", [iolist_size(Line)]).Iodata is the idea worth taking away, because it has no PHP equivalent and it explains a lot of code you will read. Concatenating strings in PHP copies both operands every time; an iodata list just refers to its pieces and is flattened once, by the runtime, at the moment it is written to a socket or a file. That is why an Erlang web framework builds a response as a deeply nested list of binaries and never calls anything resembling
implode. Anything accepting iodata accepts a binary, a list, or any nesting of them, so [<<"a">>, ["b", <<"c">>]] is perfectly ordinary. One directive to know when this meets real text: ~s writes bytes, so a UTF-8 binary containing anything above ASCII comes out mangled — ~ts is the Unicode-aware version, and reaching for it is the fix when accented characters arrive as noise.Converting between the three
PHP casts between scalar types with a syntax. Erlang converts with named functions, and the names follow a rigid pattern:
X_to_Y, where both ends are spelled out.<?php
$text = "36";
var_dump((int) $text);
var_dump((string) 36);
var_dump(strtoupper("ada"));io:format("~p~n", [list_to_integer("36")]),
io:format("~p~n", [integer_to_list(36)]),
io:format("~s~n", [string:uppercase("ada")]),
% Between a list-string and a binary:
io:format("~p~n", [list_to_binary("ada")]),
io:format("~p~n", [binary_to_list(<<"ada">>)]).The pattern is the point — once you know
list_to_integer, you can guess binary_to_integer, atom_to_list, float_to_binary and term_to_binary without looking any of them up, and they all exist. Two warnings. These functions raise rather than returning a sentinel, so list_to_integer("abc") is a badarg and not 0 — which is a real improvement on PHP's (int) "abc" being a valid-looking zero. And never call list_to_atom on user input: the atom table is never garbage collected, so a hostile client can exhaust it and bring the node down. list_to_existing_atom is the safe version.Functions & Clauses
One function, several heads
Instead of one function whose body branches on the input, Erlang writes several clauses with the same name, and the runtime picks the first whose pattern fits. This is the shape most Erlang functions take.
<?php
function area(array $shape): float {
switch ($shape["type"]) {
case "square":
return $shape["side"] ** 2;
case "rect":
return $shape["width"] * $shape["height"];
default:
throw new InvalidArgumentException("unknown shape");
}
}
echo area(["type" => "square", "side" => 3]), " ",
area(["type" => "rect", "width" => 3, "height" => 4]), "\n";Area = fun
({square, Side}) -> Side * Side;
({rect, Width, Height}) -> Width * Height
end,
io:format("~p ~p~n", [Area({square, 3}), Area({rect, 3, 4})]).Clauses are tried top to bottom, so order matters and the specific ones go first — and note the semicolons between them, with none before
end. There is no default clause here and that is deliberate: an unrecognized shape raises function_clause, naming the function and the argument, which is a better error than the InvalidArgumentException you had to remember to write. When you read an OTP module, the clause list is the specification of what that function accepts.Guards narrow a clause further
A guard is a condition attached to a clause after
when. It is what lets a clause head test more than shape, and it is why Erlang functions so rarely need an if in the body.<?php
function classify(mixed $value): string {
if (is_int($value) && $value < 0) {
return "a negative integer";
}
if (is_int($value)) {
return "an integer";
}
if (is_string($value)) {
return "a string";
}
return "something else";
}
foreach ([-3, 7, "text", 2.5] as $value) {
echo classify($value), "\n";
}Classify = fun
(V) when is_integer(V), V < 0 -> "a negative integer";
(V) when is_integer(V) -> "an integer";
(V) when is_list(V) -> "a string";
(_) -> "something else"
end,
[io:format("~s~n", [Classify(V)]) || V <- [-3, 7, "text", 2.5]].Guards are deliberately restricted to a fixed set of side-effect-free operations, so you cannot call your own function in one — a guard runs during matching, possibly many times, and must be provably terminating and harmless. Read the comma inside a guard as
and, and a semicolon inside a guard as or, which is a third meaning for both marks and worth noting. The last line is a list comprehension used purely for its side effect, which is idiomatic Erlang and looks alarming to a PHP reader: [Expr || V <- List] is "do this for each".Passing a function around
The three workhorses live in the
lists module and take the function first, which is PHP's array_map order rather than its array_filter order — Erlang is at least consistent about it.<?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);
echo implode(",", $doubled), "\n";
echo implode(",", $evens), "\n";
echo $total, "\n";Numbers = [1, 2, 3, 4, 5, 6],
Doubled = lists:map(fun(N) -> N * 2 end, Numbers),
Evens = lists:filter(fun(N) -> N rem 2 =:= 0 end, Numbers),
Total = lists:foldl(fun(N, Acc) -> Acc + N end, 0, Numbers),
io:format("~p~n~p~n~p~n", [Doubled, Evens, Total]).Two things a PHP reader gains here.
array_values has no counterpart because an Erlang list has no keys to preserve — PHP's filter keeping the original keys, so that removing element 0 leaves an array starting at key 1 that then serializes as a JSON object, is a bug class that simply does not exist. And you will often see fun Module:Function/Arity passed directly, as in lists:map(fun erlang:integer_to_list/1, Numbers), which is a reference to a named function with its arity spelled out. Note =:= rather than ==, which the Gotchas section explains.Modules & Arity
Arity is part of the name
A function is identified by its name and its number of arguments, written
greet/2. That slash is in every error message, every export list and every piece of documentation you will read.<?php
// One function, an optional parameter:
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.%% In a module these are TWO functions: greet/1 and greet/2,
%% and the export list names them separately.
Greet2 = fun(Greeting, Name) -> Greeting ++ ", " ++ Name ++ "!" end,
Greet1 = fun(Name) -> Greet2("Hello", Name) end,
io:format("~s~n~s~n", [Greet1("Ada"), Greet2("Welcome", "Ada")]).So
greet/1 and greet/2 are different functions that happen to share a name, and the shorter one delegating to the longer with a default filled in is the standard idiom — Erlang has no default arguments, so this is how they are spelled. The practical payoff when reading: an error saying undefined function counter:step/2 when step is plainly defined right there means the arity is wrong, not the name. And -export([start/0, step/1]) tells you the arity of everything public before you read a line of the body.Two ways to call, and the difference matters
PHP's qualified and unqualified calls differ only in name resolution. In Erlang the difference is semantic, and it is the mechanism behind one of the language's headline features.
<?php
// Unqualified, resolved through the current namespace:
echo strlen("hello"), "\n";
// Fully qualified, resolved from the global namespace:
echo \strlen("hello"), "\n";
// In PHP the two differ only in NAME RESOLUTION.% Unqualified — an auto-imported BIF, called locally:
io:format("~p~n", [length([1, 2, 3])]),
% Fully qualified — the same function, named by its module:
io:format("~p~n", [erlang:length([1, 2, 3])]).
% In Erlang the two differ in WHICH VERSION of the module is used.Erlang can hold two versions of a module in memory at once — the current one and the previous one. A local call stays in the version it is already running, so a long-running loop keeps executing the old code; a fully qualified
module:function(…) call jumps to the current version. That is why a gen_server loop calls itself fully qualified: it is the moment an upgrade takes effect. When you read a module and see the same function called both ways in different places, that is not inconsistency — somebody has thought about upgrades.OTP — What the Callbacks Mean
What a gen_server actually is
This is the single most important thing to be able to read, because a large Erlang system is mostly
gen_server modules. It is a process that holds state and answers messages, wrapped in a standard shape.<?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.
$basket = new Basket();
$basket->add("book");
print_r($basket->contents());-module(basket).
-behaviour(gen_server).
-export([start_link/0, add/2, contents/1]).
-export([init/1, handle_call/3, handle_cast/2]).
%% The public API — plain functions that send messages.
start_link() -> gen_server:start_link(?MODULE, [], []).
add(Pid, Item) -> gen_server:cast(Pid, {add, Item}).
contents(Pid) -> gen_server:call(Pid, contents).
%% The callbacks — these run INSIDE the process.
init([]) -> {ok, []}.
handle_cast({add, Item}, Items) -> {noreply, Items ++ [Item]}.
handle_call(contents, _From, Items) -> {reply, Items, Items}.Read the file in two halves, which is the convention every such module follows. The top half is the public API — ordinary functions a caller invokes, which send a message and wait. The bottom half is the callbacks, which run inside the process. The return tuples are the whole protocol:
{reply, Reply, NewState} answers a call, {noreply, NewState} handles a cast with nothing to say back, and {stop, Reason, State} ends the process. call is synchronous and cast is fire-and-forget. And ?MODULE is a preprocessor macro expanding to this module's name.A callback module is an interface, inverted
The OTP callback attribute names a contract — a list of functions a module must export — and the compiler warns when one is missing. It is an interface, with the direction of control reversed.
<?php
interface Handler {
public function handle(string $message): string;
}
final class EmailHandler implements Handler {
public function handle(string $message): string {
return "email: {$message}";
}
}
function dispatch(Handler $handler, string $message): void {
echo $handler->handle($message), "\n";
}
dispatch(new EmailHandler(), "deploy finished");%% The attribute names a contract; the compiler warns if a
%% required callback is missing.
-behaviour(gen_server).
%% What gets passed around is the MODULE NAME, an atom:
%% gen_server:start_link(basket, [], [])
%%
%% gen_server is ordinary Erlang code that calls back into your
%% module. Reading gen_server.erl itself is genuinely worthwhile.
io:format("~p~n", [is_atom(basket)]).PHP passes an instance and the interface constrains its type. Erlang passes the module name, which is just an atom, and the framework calls back into it. That is why configuration reads
{callback_module, my_handler} and why swapping an implementation for a test means passing a different atom — no container, no binding, no registry. The genuinely useful thing to know when reading OTP: gen_server, supervisor and the rest are not language features. They are ordinary modules in the standard library, and opening gen_server.erl to see the receive loop that drives your callbacks is a good afternoon.The supervision tree is the architecture
A supervisor is a process whose only job is to start a set of children and restart them when they die, by a declared strategy. There is no PHP counterpart inside the language.
<?php
// PHP's supervision lives outside the language: supervisord,
// systemd, or Horizon watching queue workers. The unit of
// failure is the whole process.
function flakyWorker(int $attempt): int {
if ($attempt < 2) {
throw new RuntimeException("worker died");
}
return 1;
}
foreach ([1, 2] as $attempt) {
try {
echo flakyWorker($attempt), "\n";
} catch (RuntimeException $error) {
echo "restarting after: ", $error->getMessage(), "\n";
}
}-module(basket_sup).
-behaviour(supervisor).
-export([start_link/0, init/1]).
start_link() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []).
init([]) ->
SupervisorFlags = #{strategy => one_for_one,
intensity => 5, %% at most 5 restarts
period => 10}, %% in 10 seconds
Children = [#{id => basket,
start => {basket, start_link, []},
restart => permanent}],
{ok, {SupervisorFlags, Children}}.The strategies say how far a failure reaches:
one_for_one restarts only what died, one_for_all restarts every child when any dies (for children that only make sense together), and rest_for_one restarts the ones started after it. The intensity and period pair is the give-up rule — more than five restarts in ten seconds and the supervisor itself dies, escalating to its supervisor, which is how a genuinely broken subsystem eventually takes down the node rather than thrashing forever. A real application is a tree of these, and that tree is the architecture document: reading the supervisor modules tells you what a system is made of and what depends on what.Processes & Messages
What happens between two requests
This is the architectural difference underneath everything else. 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.
<?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 process can remember across requests.
Loop = fun L(State) ->
receive
{add, N, From} -> From ! State + N, L(State + N)
end
end,
Counter = spawn(fun() -> Loop(0) end),
Counter ! {add, 1, self()},
receive V1 -> io:format("~p ", [V1]) after 1000 -> ok end,
Counter ! {add, 1, self()},
receive V2 -> io:format("count is ~p~n", [V2]) after 1000 -> ok end.The BEAM starts once and keeps running, so an Erlang application has a memory, and a cache or a rate limiter needs no external service. The trade is that you now have shared mutable state — the thing PHP eliminates by force — and the BEAM makes it safe without locks by putting the state inside a process whose only door is a mailbox. Note the shape of the loop: state is a parameter of a function that never returns, so it looks mutable without any mutation, and
gen_server is exactly this with the boilerplate filled in.Three primitives, and nothing shared
An Erlang process is not an operating-system process or a thread. It is a few hundred bytes of VM bookkeeping, starts in microseconds, and a single machine runs millions — which is why "spawn a process" is the answer to almost every concurrency question here.
<?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);
echo "got {$reply}\n";Parent = self(),
spawn(fun() ->
receive
{ping, From} -> From ! {pong, "PING"}
end
end) ! {ping, Parent},
receive
{pong, Reply} -> io:format("got ~s~n", [Reply])
after
1000 -> io:format("timed out~n")
end.Three primitives are the whole model.
spawn starts a process and returns its identifier, a pid, which prints as <0.123.0> and which you will see everywhere in logs. ! puts a message in a mailbox and returns immediately, never blocking and never failing — sending to a dead process is silently fine. receive takes the first message matching one of its patterns, waiting if none has arrived, with after as the timeout you should almost always include. Nothing is shared: each process has its own heap and the message is copied, so no lock is possible by construction.Links and monitors — how failure travels
By default a process dying is nobody's business. Two mechanisms change that, and telling them apart is essential to reading OTP: a link is bidirectional and lethal, a monitor is one-directional and informational.
<?php
// PHP's failure boundary is the request. A fatal error ends this
// request and no other; there is nothing to notify.
try {
throw new RuntimeException("worker failed");
} catch (RuntimeException $error) {
echo "handled: ", $error->getMessage(), "\n";
}
echo "the script continues\n";% A MONITOR notifies without dying alongside:
{_Pid, Reference} = spawn_monitor(fun() -> exit(deliberate) end),
receive
{'DOWN', Reference, process, _Object, Reason} ->
io:format("worker went down: ~p~n", [Reason])
after
1000 -> io:format("timed out~n")
end.A
link means "if either of us dies, so does the other", which is what spawn_link sets up and what makes a supervision tree collapse cleanly — a supervisor traps those exit signals rather than dying, which is what process_flag(trap_exit, true) is for whenever you see it. A monitor just sends you a {'DOWN', …} message, which is what gen_server:call uses internally so that calling a dead server gives you an error rather than hanging forever. The quoted 'DOWN' is an atom that needs quotes precisely because it starts with a capital letter.Crashing on Purpose
Let it crash, and why the code has no checks
This is the cultural difference that makes Erlang code look under-written to a PHP reader. There is no error handling here, and its absence is deliberate rather than sloppy.
<?php
function fetchUser(int $id): array {
$result = ["id" => $id];
if (!isset($result["id"])) {
throw new RuntimeException("no user");
}
return $result;
}
// Defensive checks at every layer are the PHP norm:
try {
$user = fetchUser(1);
echo $user["id"], "\n";
} catch (RuntimeException $error) {
echo "handled: ", $error->getMessage(), "\n";
}% The idiomatic Erlang is to NOT check. This line asserts
% that the call succeeded, and crashes the process if it did not:
Fetch = fun(Id) -> {ok, #{id => Id}} end,
{ok, User} = Fetch(1),
io:format("~p~n", [maps:get(id, User)])."Let it crash" says: do not write defensive code for cases you did not anticipate, because a process that has met an impossible situation has no idea what state it is in, and continuing is worse than stopping. The
{ok, User} = Fetch(1) line is an assertion — if the call returns {error, _} it raises badmatch, the process dies, its supervisor restarts it from a known-good state, and the failure is logged with a full stack trace. That is why a well-written Erlang module handles the errors it expects and ignores the ones it does not. Copying PHP's catch-everything habit into Erlang produces code that limps on in a corrupted state, which is strictly worse.error, exit and throw are three different things
Erlang has three exception classes, not one, and they are not interchangeable. The
catch pattern is Class:Reason, so a clause that omits the class only catches throw.<?php
// PHP has one hierarchy: Throwable, split into Error and Exception.
try {
throw new RuntimeException("something went wrong");
} catch (Throwable $error) {
echo get_class($error), ": ", $error->getMessage(), "\n";
} finally {
echo "cleanup runs either way\n";
}Run = fun(Kind) ->
try
case Kind of
error -> erlang:error(my_error);
exit -> exit(my_exit);
throw -> throw(my_throw)
end
catch
error:Reason -> io:format("error: ~p~n", [Reason]);
exit:Reason -> io:format("exit: ~p~n", [Reason]);
throw:Reason -> io:format("throw: ~p~n", [Reason])
after
io:format("cleanup runs either way~n")
end
end,
[Run(K) || K <- [error, exit, throw]].The distinction is about intent.
error means a bug — badmatch, badarg and function_clause are all this class, and it carries a stack trace. exit means this process is stopping on purpose, and it is what supervision is built on. throw means a non-local return the author expects somebody to catch, and it is comparatively rare in real code. The catch-all shorthand catch Expression that you will meet in older source catches all three and mangles the reason into {'EXIT', Reason}, which is why modern code uses the explicit try … catch Class:Reason form.Distribution & Hot Code Loading
Another machine is another pid
Distribution is built into the language rather than added as a library. Once two nodes are connected, sending a message to a process on another machine is the same
! with the same syntax.<?php
// PHP has no language-level notion of "which machine am I".
// Reaching another one means choosing a protocol and serializing:
$payload = json_encode(["event" => "signup"]);
echo $payload, "\n";
var_dump(function_exists("php_uname"));% Erlang nodes connect and then a remote process is just a pid:
% Pid ! Message % same syntax, another machine
% spawn('other@host', F) % start a process over there
%
% node() names the current node; nonode@nohost means undistributed.
io:format("~p~n", [node()]),
io:format("~p~n", [is_atom(node())]).That transparency is Erlang's most distinctive operational feature and its most double-edged. What you gain is that a system can be spread across machines without changing how processes talk. What you must know is that it is fully meshed and fully trusted — every node connects to every other, and a node that knows the shared cookie can run any code on any of them, so the distribution protocol must never be exposed to a network you do not control. It is also not partition-tolerant by default: a network split produces two halves that each think the other died. Everything Elixir does here is this same mechanism.
Replacing code without stopping
Erlang can load a new version of a module into a running system without dropping a connection. This is the feature behind the nine-nines uptime stories, and it is worth understanding even if you never use it.
<?php
// Code is a FILE, re-read (or re-cached) on the next request.
// A deploy replaces the file; nothing long-lived is upgraded.
echo basename(__FILE__), "\n";
var_dump(is_file(__FILE__));% Code is a LOADED MODULE with an identity, and the VM holds TWO
% versions at once: current and old.
% code:load_file(counter) loads a new current version
% counter:step(N) a qualified call jumps to it
% step(N) a local call stays where it is
io:format("~s~n", [filename:basename(code:which(lists))]),
io:format("~p~n", [is_list(proplists:get_value(vsn, lists:module_info(attributes)))]).The two columns print the same shape of answer and mean different things: PHP names a file on disk, Erlang names a loaded module that carries a version attribute — and that
vsn is exactly what the release upgrade machinery compares. The mechanism is the one from the calling-conventions row: two versions live in memory at once, a local call stays in the version already running, and a fully qualified call goes to the current one. So a gen_server loop that calls itself fully qualified picks up new code at its next iteration, and OTP's code_change/3 callback exists to migrate the process's state to whatever shape the new version expects. A PHP deploy has nothing to migrate because nothing survives the request — which is a real simplicity, and the exact reason PHP cannot offer this.⚠ Gotchas for PHP Developers
Two equality operators, and they are not PHP's two
Erlang also has a loose and a strict comparison, and the spelling is the trap:
== versus =:=, and /= versus =/=. The loose pair differs from PHP's in what it will coerce.<?php
var_dump(1 == 1.0); // true — juggling
var_dump(1 === 1.0); // false — different types
var_dump("1" == 1); // true
var_dump("1" === 1); // falseio:format("~p~n", [1 == 1.0]), % true — arithmetic equality
io:format("~p~n", [1 =:= 1.0]), % false — exact equality
io:format("~p~n", [1 /= 1.0]), % false — arithmetic inequality
io:format("~p~n", [1 =/= 1.0]). % true — exact inequalityErlang's
== does only numeric widening — it will compare an integer with a float and nothing else, so there is no "1" == 1 here and no string juggling at all. Idiomatic Erlang uses =:= almost everywhere, for the same reason careful PHP uses ===: it means what you expect, always. The stranger property is that any two terms can be compared with <, ordered number < atom < reference < fun < port < pid < tuple < map < list < bitstring, so 1 < a is true rather than an error. That total ordering is what makes lists:sort/1 work on anything, and it will also quietly accept a comparison you did not mean.There is no truthiness at all
PHP's falsy list is famously long. Erlang has no truthiness whatsoever:
true and false are ordinary atoms, and nothing else can stand in for them.<?php
foreach ([0, "", "0", [], null, false] as $value) {
echo var_export($value, true), " => ", $value ? "truthy" : "falsy", "\n";
}% Only the atoms true and false are booleans. A condition that
% evaluates to anything else raises badarg or if_clause.
Check = fun(V) ->
case V of
true -> "true";
false -> "false";
_ -> "not a boolean at all"
end
end,
[io:format("~s~n", [Check(V)]) || V <- [true, false, 0, []]].So
if Count -> … is not a shortcut, it is an if_clause error at run time — you write if Count > 0 -> …. Zero, the empty list and the empty binary are all just values, none of them false. Coming from PHP this removes a whole class of bug, particularly the "0" case and the if ($items) idiom that silently misreads an empty array. The related trap when reading is that andalso and orelse short-circuit while and and or evaluate both sides — the short-circuiting pair is what you nearly always want.Atoms are never garbage collected
This is the security-relevant one and it has taken down real systems. The atom table has a fixed maximum size — about a million entries by default — and entries are never freed. Both columns look the same and print the same thing; the difference is entirely in how long the table lives.
<?php
// PHP's nearest equivalent is the constant table — and it, too,
// only ever grows. The difference is how long it lives.
define("KNOWN_STATUS", "ok");
$lookup = fn(string $name): string => defined($name) ? constant($name) : "unknown";
echo $lookup("KNOWN_STATUS"), "\n";
echo $lookup("NEVER_SEEN_BEFORE_XYZ"), "\n";% list_to_atom on user input can bring the node down: the atom
% table is fixed-size and never collected.
Safe = fun(Text) ->
try list_to_existing_atom(Text)
catch error:badarg -> unknown
end
end,
io:format("~p~n", [Safe("ok")]),
io:format("~p~n", [Safe("never_seen_before_xyz")]).PHP's constant table also only grows, and it is also global — but it is torn down with the request, so the worst a hostile client achieves is wasting memory for the length of one response. The BEAM runs for months, so calling
list_to_atom/1 or binary_to_atom/2 on anything a client controls is a denial of service waiting to happen: enough distinct values and the node dies with system_limit. The safe version is list_to_existing_atom/1, which raises badarg rather than creating an entry, and the try-and-fall-back shape above is the idiom. The same hazard exists in Elixir with String.to_atom/1, and it is why Phoenix converts request parameters to atoms only at a controlled boundary.A computed value that nobody keeps is lost
PHP's array functions are split between those returning a new array and those modifying in place and returning a boolean —
sort, shuffle and array_push are all the second kind. Every Erlang function is the first kind.<?php
$names = ["grace", "ada"];
sort($names); // sorts IN PLACE, returns a bool
print_r($names); // CHANGED
print_r($names);Names = ["grace", "ada"],
lists:sort(Names), % computed, and thrown away
io:format("~p~n", [Names]), % unchanged
Sorted = lists:sort(Names), % you must keep the result
io:format("~p~n", [Sorted]).Nothing in Erlang mutates, so a line calling a function and ignoring its result does nothing at all — and, unlike PHP, there is no
sort-style exception to remember. The compiler warns about some of these but not once the call is inside a larger expression. The habit to build when reading is that any line not doing I/O should bind its result or be the last expression of a clause; if it does neither, it is dead code and possibly a bug somebody left behind.