PONYλM2Modula-2

PHP.CodeCompared.To/Elm

An interactive executable cheatsheet comparing PHP and Elm

PHP 8.3.12 (Wandbox), 8.3.11 (Judge0) or 8.5.10 (in-browser) Elm 0.19.1
Output & Running
Hello, World — and the missing echo
There is no echo, and there is nothing that behaves like one. main is not a function that runs and emits output — it is a value, of type Html, that the Elm runtime renders into the page.
<?php echo "Hello, World!\n";
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = text "Hello, World!"
That is the deepest difference on the page and it is visible in the first row. PHP's whole model is that code executes and pushes bytes at a response; an Elm program describes what should be on screen and hands that description to a runtime. Nothing in the Elm column performs an action, which is why there is no semicolon, no statement, and nowhere for a side effect to hide. The msg in Html msg is a type variable meaning "this markup produces no messages" — it is inert, which is exactly right for a page with no interaction.
Showing a number means converting it
PHP converts anything to a string the moment it meets one, so "sum: " . 43.5 just works. Elm converts nothing implicitly: ++ joins two Strings and only two Strings.
<?php $count = 42; $ratio = 1.5; echo "count: {$count}\n"; echo "ratio: {$ratio}\n"; echo "sum: " . ($count + $ratio) . "\n";
module Main exposing (main) import Html exposing (Html, text) count : Int count = 42 ratio : Float ratio = 1.5 main : Html msg main = text ("count: " ++ String.fromInt count ++ ", ratio: " ++ String.fromFloat ratio ++ ", sum: " ++ String.fromFloat (toFloat count + ratio) )
So every number reaching the screen goes through String.fromInt or String.fromFloat, and mixing an Int with a Float needs toFloat first — there is no numeric tower quietly promoting one to the other. That is more typing, and it is the same discipline that makes "1" == 1 impossible: PHP's juggling is one mechanism, used for convenience here and producing surprises elsewhere. Note also that ++ is string concatenation, not +, precisely so that adding numbers and joining strings can never be confused.
Layout is significant, and there are no braces
Elm has no braces, no semicolons and no return. A definition is a name, an =, and an expression; the body's indentation is what says where it ends.
<?php // A line comment /* A block comment */ function double(int $value): int { return $value * 2; } echo double(21), "\n";
module Main exposing (main) import Html exposing (Html, text) -- A line comment {- A block comment -} double : Int -> Int double value = value * 2 main : Html msg main = text (String.fromInt (double 21))
Two conventions that look like rules and are: definitions at the top level are separated by two blank lines, and the body goes on its own line indented by four spaces. That is elm-format's output, and because virtually every Elm project runs it on save, the community has no style debates at all — there is one layout and a program produces it. Coming from PHP's many possible bracing styles, this takes about a day to stop noticing.
Types, Checked Before It Runs
Type declarations, checked before the program exists
PHP 8 with strict_types gives you real type declarations, and they are genuinely enforced — so an experienced PHP developer may reasonably ask what Elm adds. The answer is when.
<?php declare(strict_types=1); function double(int $value): int { return $value * 2; } echo double(21), "\n"; // This is a TypeError — but only when this line runs: try { echo double("nonsense"), "\n"; } catch (TypeError $error) { echo "caught at run time\n"; }
module Main exposing (main) import Html exposing (Html, text) double : Int -> Int double value = value * 2 main : Html msg main = text (String.fromInt (double 21)) -- double "nonsense" would not COMPILE. There is no program -- in which that line exists to be caught.
A PHP type declaration is a run-time assertion: the check happens at the call, on the request that made the call, in production if that is where the bad call happens. An Elm annotation is checked by the compiler, so the program containing a bad call cannot be built at all — the commented line above is not a caught error, it is a build failure. That difference compounds: because every call is checked, a change to double's signature immediately lists every place that no longer fits, which is refactoring with a checklist instead of grep. The annotation is also optional — Elm infers every type on its own, and people write signatures as documentation.
A shape gets a name
PHP describes the shape of an array in a docblock that only a static analyzer reads, or promotes it to a class with a constructor and properties. type alias is the lightweight middle option PHP does not have.
<?php // An array's shape lives in a docblock or a class: /** @param array{name: string, age: int} $person */ function describe(array $person): string { return "{$person['name']} is {$person['age']}"; } echo describe(["name" => "Ada", "age" => 36]), "\n";
module Main exposing (main) import Html exposing (Html, text) type alias Person = { name : String , age : Int } describe : Person -> String describe person = person.name ++ " is " ++ String.fromInt person.age main : Html msg main = text (describe { name = "Ada", age = 36 })
It is only a name for a shape — there is no class, no constructor and no methods, and any record with those exact fields is a Person. Two consequences a PHP developer should sit with. Passing a record with a missing field is a compile error naming the field, where $person['nam'] is a warning and null. And the alias doubles as a constructor function: Person "Ada" 36 builds one positionally, which is why you will see aliases used as if they were classes even though nothing was declared.
Generics, without a docblock
PHP has no generics in the language, so a container of a known type is expressed in a docblock that PHPStan or Psalm reads and the runtime ignores. In Elm a lowercase name in a signature is a type variable.
<?php /** * @template T * @param list<T> $items * @return T|null */ function firstItem(array $items) { return $items[0] ?? null; } var_dump(firstItem([10, 20])); var_dump(firstItem([]));
module Main exposing (main) import Html exposing (Html, text) firstItem : List a -> Maybe a firstItem items = List.head items main : Html msg main = let empty : List Int empty = [] in text (Debug.toString (firstItem [ 10, 20 ]) ++ " / " ++ Debug.toString (firstItem empty) )
List a -> Maybe a says: give me a list of anything, and I hand back a Maybe of that same thing. The compiler enforces the connection, so firstItem [ "a" ] is a Maybe String and using it as a number is a build error. Compare the PHP version, whose declared return type has to be omitted entirely — you cannot write T|null in real PHP syntax, only in the comment — so the runtime knows nothing and the annotation is a promise nobody keeps. Note also the return type here: Maybe a rather than T|null, which is the next section.
There Is No Null
null does not exist as a value
PHP 7.1 added nullable types, so ?string documents that a function may return nothing. Elm goes further: there is no null value in the language, so "may return nothing" has to be expressed as a different type.
<?php function findUser(int $id): ?string { return $id === 1 ? "Ada" : null; } $name = findUser(2); // Forgetting this check is the classic PHP bug: echo strtoupper($name ?? "nobody"), "\n";
module Main exposing (main) import Html exposing (Html, text) findUser : Int -> Maybe String findUser id = if id == 1 then Just "Ada" else Nothing main : Html msg main = text (String.toUpper (Maybe.withDefault "nobody" (findUser 2)))
That difference is not stylistic. ?string in PHP still hands you something you can pass to strtoupper, and the failure arrives later as a deprecation or a TypeError — a null-related error is still one of the commonest bugs in any PHP codebase. Maybe String is not a String, so no function taking a String will accept it and the compiler forces you to deal with the Nothing case before you can use the value at all. This is the single most-cited reason Elm advertises "no runtime exceptions", and it costs exactly what you see: one Maybe.withDefault.
Working inside a Maybe without unwrapping it
The PHP idiom for "do something to a value that might be null" is a ternary guarding the operation. Maybe.map is that pattern given a name: it applies the function when there is a value and does nothing when there is not.
<?php function findAge(string $name): ?int { $ages = ["Ada" => 36]; return $ages[$name] ?? null; } $age = findAge("Ada"); $nextYear = $age === null ? null : $age + 1; echo $nextYear ?? "unknown", "\n"; echo findAge("Grace") === null ? "unknown\n" : "known\n";
module Main exposing (main) import Html exposing (Html, text) findAge : String -> Maybe Int findAge name = if name == "Ada" then Just 36 else Nothing main : Html msg main = let nextYear = Maybe.map (\age -> age + 1) (findAge "Ada") in text (Maybe.withDefault "unknown" (Maybe.map String.fromInt nextYear) ++ " / " ++ Maybe.withDefault "unknown" (Maybe.map String.fromInt (findAge "Grace")) )
PHP's nullsafe operator ?-> is the same idea for method calls, and it is worth naming the difference: ?-> works only on property and method access on objects, while Maybe.map takes any function at all. The habit worth building is to stay inside the Maybe for as long as possible and unwrap once, at the edge, with withDefault — the equivalent of doing your null check at the point of display rather than threading === null through five functions. Maybe.andThen is the version for a function that itself returns a Maybe, which is where a chain of ?? would otherwise appear.
A missing key is a Maybe, not a warning
A PHP associative array with arbitrary string keys is an Elm Dict, and its lookup returns a Maybe for the same reason as everything else in this section — the key might not be there, and the type says so.
<?php $ages = ["Ada" => 36, "Grace" => 45]; echo $ages["Ada"], "\n"; echo $ages["Alan"] ?? "not found", "\n"; // Without the ??, PHP 8 emits a warning and evaluates to null: // echo $ages["Alan"];
module Main exposing (main) import Dict import Html exposing (Html, text) main : Html msg main = let ages = Dict.fromList [ ( "Ada", 36 ), ( "Grace", 45 ) ] in text (Maybe.withDefault "?" (Maybe.map String.fromInt (Dict.get "Ada" ages)) ++ " / " ++ Maybe.withDefault "not found" (Maybe.map String.fromInt (Dict.get "Alan" ages)) )
The comparison worth making is with $ages["Alan"] without the ??. PHP emits a warning and evaluates to null, so the program keeps going with a value that means "there was a bug on the line above" — and in production, where warnings are usually not displayed, it keeps going silently. Dict.get hands back Nothing, which is not an Int, so nothing downstream can accidentally treat it as one. Note that a Dict is for keys you do not know at compile time; when the fields are fixed and known, use a record, which is the next section.
Immutability & Expressions
A name is bound once
Elm has no assignment at all, only definition — and a name may be defined once. There is no way to write count = count + 1, and shadowing an existing name is a compile error too.
<?php $count = 1; $count = $count + 1; // reassignment is ordinary PHP echo $count, "\n";
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = let count = 1 nextCount = count + 1 in text (String.fromInt nextCount)
The immediate consequence is that a value never changes under you, so reading a function means reading it once rather than tracing what happened to a variable along the way. The second consequence is that loops disappear: a for needs a counter to increment, so repetition is List.map, List.filter and List.foldl instead. let … in is the local-binding expression, and note that it is an expression — the whole thing has a value, which is what lets it sit inside the text (…) call.
if produces a value, and there is always an else
PHP 8's match is an expression, but if is a statement, so the branches assign into a variable declared outside. Every Elm if is an expression, so you bind the whole thing.
<?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";
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = let temperature = 18 advice = if temperature > 25 then "wear shorts" else if temperature > 15 then "a jacket will do" else "wear a coat" in text advice
The rule that follows is the one to remember: an Elm if must have an else. There is no such thing as a half-finished conditional, because the expression has to produce a value on every path — and both branches must produce the same type. That closes the hole where a PHP if chain with no else leaves $advice undefined, which is a warning and a null rather than an error. There is no elseif keyword; it is else if, two words, which is just a nested if in the else branch.
There is nothing to do in sequence
The PHP function is a sequence of statements, each changing the world a little before the next runs. The Elm function is a single expression with named intermediate parts — nothing happens in an order you could observe.
<?php function report(array $numbers): string { $total = 0; foreach ($numbers as $number) { $total += $number; // a statement, mutating as it goes } $average = $total / count($numbers); return "total {$total}, average {$average}"; } echo report([10, 20, 30]), "\n";
module Main exposing (main) import Html exposing (Html, text) report : List Int -> String report numbers = let total = List.sum numbers average = toFloat total / toFloat (List.length numbers) in "total " ++ String.fromInt total ++ ", average " ++ String.fromFloat average main : Html msg main = text (report [ 10, 20, 30 ])
That is why there is no return: a function is its expression, so there is nothing to return from and nowhere to return early to. Bindings in a let are not steps; they are names for parts of one expression, and the compiler is free to evaluate them in any order, or not at all if unused. If you find yourself wanting an early return for a validation check, the answer is Maybe or Result and the functions that chain them, which is the Errors section below.
Functions & Currying
Declaring and calling a function
A call needs no parentheses and no commas — greet "Hello" "Ada" is the whole thing. What Elm does not have is a default argument, which is why the greeting has to be passed both times.
<?php function greet(string $name, string $greeting = "Hello"): string { return "{$greeting}, {$name}!"; } echo greet("Ada"), "\n"; echo greet("Ada", "Welcome"), "\n";
module Main exposing (main) import Html exposing (Html, text) greet : String -> String -> String greet greeting name = greeting ++ ", " ++ name ++ "!" main : Html msg main = text (greet "Hello" "Ada" ++ " / " ++ greet "Welcome" "Ada")
Two adjustments for a PHP eye. The parentheses come back the moment an argument is itself a call — String.fromInt (double 21) needs them, because String.fromInt double 21 would mean passing two arguments to String.fromInt. And the argument order is reversed from what you might expect: the greeting comes first because the data usually comes last, which is the convention that makes currying and the pipe operator work — the next two rows.
Every function takes exactly one argument
Read the signature again: String -> String -> String. There is no grouping in it because there are no two-argument functions in Elm — greet takes one String and returns a function that takes the next one.
<?php function greet(string $greeting, string $name): string { return "{$greeting}, {$name}!"; } // PHP's version of a pre-filled argument is a closure or an arrow fn: $sayHello = fn(string $name): string => greet("Hello", $name); echo $sayHello("Ada"), "\n"; echo $sayHello("Grace"), "\n";
module Main exposing (main) import Html exposing (Html, text) greet : String -> String -> String greet greeting name = greeting ++ ", " ++ name ++ "!" sayHello : String -> String sayHello = greet "Hello" main : Html msg main = text (sayHello "Ada" ++ " / " ++ sayHello "Grace")
So greet "Hello" is a complete, valid expression with type String -> String, and giving a function fewer arguments than it looks like it wants is normal rather than an error. That is what makes sayHello a definition with no parameters at all. PHP's nearest equivalent is the arrow function shown beside it, and the difference is that PHP needs you to write the wrapper while Elm gets it for free from the shape of every function. The practical payoff is in List.map (greet "Hi") names, where the partially applied function is exactly what map wants.
The pipe feeds the LAST argument
Elm has a pipe operator and it reads left to right like any other. The trap is which slot it fills: value |> function puts the value in the last argument position, not the first.
<?php $names = [" ada ", " grace "]; $result = array_map( "ucfirst", array_map("trim", $names) ); echo implode(", ", $result), "\n";
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = [ " ada ", " grace " ] |> List.map String.trim |> List.map (\name -> String.toUpper (String.left 1 name) ++ String.dropLeft 1 name) |> String.join ", " |> text
That is the opposite of every other pipe on this site, and it follows directly from currying. List.map String.trim is a partially applied function still waiting for its list, so piping a list into it completes the call — which only works because the list is the last parameter. The whole standard library is ordered that way: List.map f list, String.join separator list, Dict.get key dict. Coming from PHP the practical rule is: put the data last in your own functions too, or your pipelines will not compose.
Arrays Become Records
An associative array with known keys becomes a record
A record is written with = rather than =>, read with a dot, and updated with the { record | field = value } syntax, which returns a new record rather than changing the old one.
<?php $person = ["name" => "Ada", "age" => 36]; echo $person["name"], "\n"; $older = $person; $older["age"] = 37; echo $older["age"], " ", $person["age"], "\n";
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = let person = { name = "Ada", age = 36 } older = { person | age = 37 } in text (person.name ++ " " ++ String.fromInt older.age ++ " " ++ String.fromInt person.age)
The update syntax has a property worth noticing: it cannot add a field. { person | nickname = "A" } is a compile error, because the type of person has no such field and a record's field set is fixed by its type. PHP's $older["nickname"] = "A" silently creates one, which is convenient when you meant it and a typo-shaped bug when you did not. The field access person.name is also just a function: .name on its own is a perfectly good function of type { a | name : b } -> b, so List.map .name people works.
A record is data, and only data
PHP bundles the data and the operations on it into a class, so area is reached through the value. Elm keeps them apart: a record holds fields, and a function takes one as its first argument.
<?php class Rectangle { public function __construct( public float $width, public float $height, ) {} public function area(): float { return $this->width * $this->height; } } $shape = new Rectangle(3, 4); echo $shape->area(), "\n";
module Main exposing (main) import Html exposing (Html, text) type alias Rectangle = { width : Float , height : Float } area : Rectangle -> Float area rectangle = rectangle.width * rectangle.height main : Html msg main = text (String.fromFloat (area { width = 3, height = 4 }))
This is the same trade every functional language makes, and the concrete effects are worth listing. There is no $this, so a function's inputs are exactly its parameters and nothing is reachable that is not written down. There is no inheritance, so behavior cannot be changed by something further up a hierarchy. And there is no constructor, so there is nowhere to validate on construction — the Elm answer is a function returning Result, in the Errors section, which is more honest than a constructor that can only fail by throwing. What you lose is the convenience of $shape->area() discovering itself in an IDE from the value.
Lists
Every element has the same type
A PHP array is a heterogeneous ordered hash map: any mix of types, any mix of keys. An Elm List is a sequence of values that all share one type, and mixing them is a compile error.
<?php // A PHP array holds anything, in any mixture: $mixed = [1, "two", 3.0, true, ["nested"]]; echo count($mixed), "\n"; echo gettype($mixed[1]), "\n";
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = let numbers : List Int numbers = [ 1, 2, 3 ] in -- [ 1, "two" ] would not compile: a List holds ONE type. text (String.fromInt (List.length numbers) ++ " / " ++ Debug.toString numbers)
That single restriction is what makes List.sum, List.map and the rest safe with no run-time checks — List.sum takes a List number, so there is no possibility of a string turning up halfway through. When you genuinely need a mixture, the Elm answer is a custom type naming the alternatives, which is the next section, and it forces you to handle each case rather than discovering the mixture at run time. When you need integer keys, you want a List; when you need string keys, a Dict; when you need fixed named fields, a record. PHP's one array covers all three, which is why it never asks you to choose.
map, filter and reduce
The three workhorses map across directly, with the collection last in every signature — the convention that makes them pipeable. A lambda is written \parameter -> body.
<?php $numbers = [1, 2, 3, 4, 5, 6]; $doubled = array_map(fn($number) => $number * 2, $numbers); $evens = array_values(array_filter($numbers, fn($number) => $number % 2 === 0)); $total = array_reduce($numbers, fn($carry, $number) => $carry + $number, 0); echo implode(",", $doubled), "\n"; echo implode(",", $evens), "\n"; echo $total, "\n";
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = let numbers = [ 1, 2, 3, 4, 5, 6 ] doubled = List.map (\number -> number * 2) numbers evens = List.filter (\number -> modBy 2 number == 0) numbers total = List.foldl (+) 0 numbers in text (Debug.toString doubled ++ " " ++ Debug.toString evens ++ " " ++ String.fromInt total)
Three PHP annoyances vanish on the way. array_map takes the callback first while array_filter takes it second, an inconsistency every PHP developer has looked up; Elm's are uniform. array_values is needed because PHP's filter preserves keys, so removing element 0 leaves an array starting at key 1 that then serializes as a JSON object instead of an array — an Elm List has no keys to preserve. And List.foldl (+) 0 shows an operator used as a function by wrapping it in parentheses, which PHP has no equivalent for at all. Note modBy 2 number rather than %: the argument order is deliberate, so modBy 2 partially applies to "is even".
There is no $list[2]
An Elm List is a singly linked list, so there is no indexing syntax and no List.get. Reaching element two means dropping two and taking the head, and the head is a Maybe because the list might be empty.
<?php $friends = ["Grace", "Alan", "Edsger"]; echo $friends[0], "\n"; echo $friends[2], "\n"; // $friends[9] is a warning and null.
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = let friends = [ "Grace", "Alan", "Edsger" ] in text (Maybe.withDefault "none" (List.head friends) ++ " / " ++ Maybe.withDefault "none" (List.head (List.drop 2 friends)) ++ " / " ++ Maybe.withDefault "none" (List.head (List.drop 9 friends)) )
This is genuinely more awkward than $friends[2] and it is worth saying so rather than presenting it as a feature. What it is, is honest: $friends[9] in PHP is a warning and a null that travels, while the Elm version cannot produce a value that is not there. If you find yourself reaching by index often, that usually means the data wanted to be a record with named fields or a Dict, and reaching for Array from elm/array — which does have Array.get : Int -> Array a -> Maybe a — is the answer when you truly need random access.
Custom Types & Pattern Matching
Enums, and what a custom type adds
PHP 8.1 enums make this row nearly a translation, and both languages will complain if you forget a case — PHP's match throws UnhandledMatchError at run time, Elm's case fails to compile.
<?php enum Status { case Active; case Suspended; case Closed; } function describe(Status $status): string { return match ($status) { Status::Active => "in good standing", Status::Suspended => "temporarily blocked", Status::Closed => "gone", }; } echo describe(Status::Suspended), "\n";
module Main exposing (main) import Html exposing (Html, text) type Status = Active | Suspended | Closed describe : Status -> String describe status = case status of Active -> "in good standing" Suspended -> "temporarily blocked" Closed -> "gone" main : Html msg main = text (describe Suspended)
That is the same when-not-whether difference as the types section, and it matters most during a change: add a fourth Status and Elm lists every case in the codebase that no longer covers it, before the build finishes. PHP tells you when a request happens to hit one. The larger difference is in the next row — a PHP enum case is a bare label (a backed enum can carry one scalar), while an Elm constructor can carry any data, which is what makes custom types the replacement for a class hierarchy rather than just for constants.
A constructor can carry data
This is where a custom type stops resembling an enum. Each constructor may carry its own fields of its own types, so Shape describes a closed set of alternatives in three lines — the job PHP gives to an abstract class and two subclasses.
<?php // PHP models "one of several shapes, each with its own fields" // with a class hierarchy or an interface: abstract class Shape {} final class Square extends Shape { public function __construct(public float $side) {} } final class Rect extends Shape { public function __construct(public float $width, public float $height) {} } function area(Shape $shape): float { return match (true) { $shape instanceof Square => $shape->side * $shape->side, $shape instanceof Rect => $shape->width * $shape->height, }; } echo area(new Square(3)), " ", area(new Rect(3, 4)), "\n";
module Main exposing (main) import Html exposing (Html, text) type Shape = Square Float | Rect Float Float area : Shape -> Float area shape = case shape of Square side -> side * side Rect width height -> width * height main : Html msg main = text (String.fromFloat (area (Square 3)) ++ " " ++ String.fromFloat (area (Rect 3 4)))
The word doing the work is closed. Nothing outside this file can add a Shape, so case can be checked for completeness and the compiler guarantees area handles every shape that will ever exist. A PHP hierarchy is open by design — anyone can extend Shape, which is the point of inheritance and also why match (true) above needs a default branch it does not have, and will throw when it meets a shape added later. Pick the model to fit: a closed set of variants known in advance is a custom type's home ground, and an open set of implementations is where interfaces belong.
case takes the value apart while it chooses
PHP's match compares a subject against values, so a branch decides and then has to reach back into the data. Elm's case matches against patterns, so choosing the branch and naming the payload are one act.
<?php $results = [ ["ok", 42], ["error", "timed out"], ]; foreach ($results as $result) { echo match ($result[0]) { "ok" => "got {$result[1]}", "error" => "failed: {$result[1]}", }, "\n"; }
module Main exposing (main) import Html exposing (Html, text) type Outcome = Success Int | Failure String describe : Outcome -> String describe outcome = case outcome of Success value -> "got " ++ String.fromInt value Failure reason -> "failed: " ++ reason main : Html msg main = text (describe (Success 42) ++ " / " ++ describe (Failure "timed out"))
Look at what the PHP version has to do: match on $result[0], then index $result[1] inside each arm, with nothing checking that a "ok" row really carries an integer or that an "error" row carries a string. The Elm version binds value as an Int and reason as a String because the constructor said so. This is also the shape that replaces returning a two-element array to signal success or failure — and Elm has a built-in type for exactly that, which is the next section.
Errors Without Exceptions
There is no throw, and no try
Elm has no exceptions. Not "discourages them" — there is no throw, no try, no catch, and no way for a function to do anything other than return a value of its declared type.
<?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"; } }
module Main exposing (main) import Html exposing (Html, text) parseAge : String -> Result String Int parseAge input = case String.toInt input of Just age -> Ok age Nothing -> Err ("not a number: " ++ input) describe : Result String Int -> String describe result = case result of Ok age -> String.fromInt age Err reason -> "rejected: " ++ reason main : Html msg main = text (describe (parseAge "36") ++ " / " ++ describe (parseAge "abc"))
So failure has to be part of the return type, and Result error value is the built-in shape for it: Ok carrying the success or Err carrying the reason. The advantage over an exception is that it cannot be ignored by accident and cannot travel: a PHP InvalidArgumentException unwinds until something catches it, possibly several frames from anywhere with the context to handle it, and nothing in the signature said it might. Result is in the signature, so every caller sees it. Note String.toInt returning a Maybe rather than 0 — PHP's (int) "abc" is 0, which is a valid-looking age.
Chaining steps that can each fail
The PHP function is a sequence of guard clauses, each throwing on the way. Result.andThen is how the same sequence is written when a failure is a value: it runs the next step only if the previous one succeeded, and passes any Err straight through.
<?php function parsePositive(string $input): int { if (!ctype_digit($input)) { throw new InvalidArgumentException("not a number"); } $value = (int) $input; if ($value <= 0) { throw new InvalidArgumentException("not positive"); } return $value; } foreach (["12", "0", "abc"] as $input) { try { echo parsePositive($input), "\n"; } catch (InvalidArgumentException $error) { echo $error->getMessage(), "\n"; } }
module Main exposing (main) import Html exposing (Html, text) parsePositive : String -> Result String Int parsePositive input = String.toInt input |> Result.fromMaybe "not a number" |> Result.andThen (\value -> if value > 0 then Ok value else Err "not positive" ) describe : Result String Int -> String describe result = case result of Ok value -> String.fromInt value Err reason -> reason main : Html msg main = text (describe (parsePositive "12") ++ " / " ++ describe (parsePositive "0") ++ " / " ++ describe (parsePositive "abc") )
Compare the two shapes. In PHP each check is an early exit, and reading the function means tracking which exits are still ahead of you. In Elm the pipeline lists the steps in order and the failure path is handled once, at the end, by whoever cares. Result.fromMaybe is the adapter that turns "there was nothing" into "there was nothing, and here is why", which is the everyday job of adding a reason to a Maybe. Result.map is the version for a step that cannot itself fail — the same relationship Maybe.map has to Maybe.andThen.
What "no runtime exceptions" actually means
Elm's marketing line is "no runtime exceptions in practice", and it is worth being precise about what that claims, because the claim is narrower than it sounds and stronger than it looks.
<?php // Every one of these is a run-time failure in PHP: $numbers = []; try { echo $numbers[0], "\n"; // warning + null echo intdiv(1, 0), "\n"; // DivisionByZeroError } catch (Throwable $error) { echo get_class($error), "\n"; }
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = let numbers : List Int numbers = [] in -- List.head [] is Nothing, not a crash. -- 1 // 0 is 0 in Elm, not an error. text (Debug.toString (List.head numbers) ++ " / " ++ String.fromInt (1 // 0) )
It means the compiler has eliminated the categories PHP calls TypeError, Error and undefined-anything: no null dereference, no missing method, no wrong argument type, no unhandled case. It does not mean every operation succeeds — it means every operation returns a value of its declared type, and so operations that could fail return Maybe or Result instead of exploding. The odd corner is integer division: 1 // 0 is 0 rather than an error, which is a deliberate choice to keep Int -> Int -> Int total and is the one place where the answer is arguably worse than PHP's DivisionByZeroError. Elm does have Debug.todo, which crashes on purpose; it is a development tool and cannot appear in an optimized build.
Rendering HTML
Markup is a value, not a string
The Elm column renders live in the preview pane; the PHP column prints the HTML string it built. Elm has no template language — div [ attributes ] [ children ] is an ordinary function call producing a typed Html value.
<?php $name = "Ada Lovelace"; $role = "Founder"; // Blade or Twig would template this; the output is a STRING either way, // and htmlspecialchars is yours to remember. echo '<div style="padding:16px;border-radius:10px;background:#4F5B93;color:white;font-family:system-ui,sans-serif">' . '<h2 style="margin:0 0 4px">' . htmlspecialchars($name) . '</h2>' . '<p style="margin:0;opacity:0.85">' . htmlspecialchars($role) . '</p>' . '</div>' . "\n";
module Main exposing (main) import Html exposing (Html, div, h2, p, text) import Html.Attributes exposing (style) main : Html msg main = div [ style "padding" "16px" , style "border-radius" "10px" , style "background" "#4F5B93" , style "color" "white" , style "font-family" "system-ui, sans-serif" ] [ h2 [ style "margin" "0 0 4px" ] [ text "Ada Lovelace" ] , p [ style "margin" "0", style "opacity" "0.85" ] [ text "Founder" ] ]
Two things follow that a PHP developer will feel immediately. There is no escaping to remember: text produces a text node, so a string containing <script> becomes those characters on screen and cannot become markup — the XSS class that htmlspecialchars and Blade's {{ }} exist to prevent has no way to occur, because a string is never parsed as HTML. And an unclosed tag is not a thing: you are calling functions and building lists, so the structure is well-formed by construction rather than by care. What you give up is the ability to hand a designer a template file.
A loop in a template is just List.map
A template language needs its own loop construct — Blade's @foreach, Twig's {% for %} — because the template is a string being assembled. Elm needs nothing new: the children of an element are a List Html, so building them is List.map.
<?php $posts = ["Shipping the API", "Why no null", "The update loop"]; // Blade: @foreach ($posts as $post) <li>{{ $post }}</li> @endforeach echo '<ul style="font-family:system-ui,sans-serif">'; foreach ($posts as $post) { echo '<li>' . htmlspecialchars($post) . '</li>'; } echo '</ul>', "\n";
module Main exposing (main) import Html exposing (Html, li, text, ul) import Html.Attributes exposing (style) posts : List String posts = [ "Shipping the API", "Why no null", "The update loop" ] main : Html msg main = ul [ style "font-family" "system-ui, sans-serif" ] (List.map (\post -> li [] [ text post ]) posts)
That is the whole templating story, and it is why Elm has no template syntax to learn. Every tool that works on lists works on markup: List.filter to hide items, List.sortBy to order them, ++ to append a footer row, and a function returning Html msg to factor out a partial. Compare what a Blade partial is — a separate file, included by name, with variables passed through an array and no checking that they are the ones it uses. An Elm "partial" is a function with a type signature, and calling it wrongly does not build.
Decoding JSON
json_decode trusts; a decoder verifies
This is the row where Elm asks the most of you, so it is worth understanding why. json_decode hands back whatever was in the payload as arrays and scalars. A decoder is a description of what you expect, and running it either produces your type or explains what was wrong.
<?php $payload = '{"name": "Ada", "age": 36}'; $data = json_decode($payload, true); // Nothing has checked that these keys exist or hold what you expect: echo $data["name"], " is ", $data["age"], "\n";
module Main exposing (main) import Html exposing (Html, text) import Json.Decode as Decode type alias Person = { name : String , age : Int } personDecoder : Decode.Decoder Person personDecoder = Decode.map2 Person (Decode.field "name" Decode.string) (Decode.field "age" Decode.int) main : Html msg main = case Decode.decodeString personDecoder "{\"name\": \"Ada\", \"age\": 36}" of Ok person -> text (person.name ++ " is " ++ String.fromInt person.age) Err error -> text ("bad payload: " ++ Decode.errorToString error)
The cost is real: a decoder is more code than json_decode, and for a three-field record it feels like ceremony. What it buys is that the boundary is the only place a shape can surprise you. In PHP, a server that renames age to years produces null that flows onward and fails somewhere unrelated, at some later request, with a message about the wrong thing. Here the decode fails immediately with Expecting an OBJECT with a field named `age`, and Ok/Err forces you to say what should happen. Once past that boundary, every field is guaranteed present and of the right type for the rest of the program's life.
An optional field is a Maybe, and says so
Optionality has to be declared, in two places that agree: Maybe String in the type, and Decode.maybe in the decoder. Neither is inferred from the payload.
<?php $payload = '{"name": "Ada"}'; $data = json_decode($payload, true); // The nickname may or may not be there, and only ?? says so: echo $data["name"], " (", $data["nickname"] ?? "no nickname", ")\n";
module Main exposing (main) import Html exposing (Html, text) import Json.Decode as Decode type alias Person = { name : String , nickname : Maybe String } personDecoder : Decode.Decoder Person personDecoder = Decode.map2 Person (Decode.field "name" Decode.string) (Decode.maybe (Decode.field "nickname" Decode.string)) main : Html msg main = case Decode.decodeString personDecoder "{\"name\": \"Ada\"}" of Ok person -> text (person.name ++ " (" ++ Maybe.withDefault "no nickname" person.nickname ++ ")") Err error -> text (Decode.errorToString error)
That is the difference between "this field happened to be missing this time" and "this field is optional". PHP's ?? is written at the point of use, so the same field can be treated as required in one place and optional in another with nothing noticing — and a field that is required but absent looks identical to one that is genuinely optional. Here the type carries the fact everywhere the value goes, so every use has to acknowledge it. Decode.oneOf handles a field that arrives in more than one shape, and Decode.map3 through map8 extend this to bigger records; past that, the community reaches for NoRedInk/elm-json-decode-pipeline.
The Elm Architecture
Model, update, view — a request loop that never ends
The Elm Architecture is one pattern for the whole application, and it will look familiar: a piece of state, a set of things that can happen, a function producing the next state, and a function rendering it. The Elm column is interactive — press the buttons.
<?php // The PHP shape: state lives in the session or the database, // a request arrives, a handler mutates, a page is rendered. session_start(); $_SESSION["count"] ??= 0; if (($_GET["action"] ?? "") === "increment") { $_SESSION["count"]++; } echo "count: ", $_SESSION["count"], "\n";
module Main exposing (main) import Browser import Html exposing (Html, button, div, span, text) import Html.Attributes exposing (style) import Html.Events exposing (onClick) type alias Model = { count : Int } type Msg = Increment | Decrement update : Msg -> Model -> Model update message model = case message of Increment -> { model | count = model.count + 1 } Decrement -> { model | count = model.count - 1 } view : Model -> Html Msg view model = div [ style "font-family" "system-ui, sans-serif", style "font-size" "20px" ] [ button [ onClick Decrement ] [ text "-" ] , span [ style "padding" "0 12px" ] [ text (String.fromInt model.count) ] , button [ onClick Increment ] [ text "+" ] ] main : Program () Model Msg main = Browser.sandbox { init = { count = 0 }, update = update, view = view }
Read it as the request cycle you already know, with the loop moved into the browser. Msg is the set of possible requests, and it is a closed custom type, so there is no route that does not exist. update is the handler, and its signature Msg -> Model -> Model says it cannot do anything except compute the next state — no database call hidden inside, no session write, nothing. view is the template, and it takes the state as an argument rather than reaching for a global. The $_SESSION superglobal in the PHP column is the honest contrast: it is reachable from anywhere, writable by anything, and its shape is whatever the last handler left in it.
⚠ Gotchas for PHP Developers
Nothing is ever coerced
PHP has two equality operators because it juggles types, and PHP 8 changed several of the rules. Elm has one, and it does not need a strict variant because there is no loose comparison to be strict about.
<?php var_dump("1" == 1); // true — PHP juggles var_dump("1" === 1); // false var_dump("10" + 5); // int(15) — the string became a number var_dump("abc" . 5); // string "abc5"
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = -- "1" == 1 does not compile: you cannot compare a String with an Int. -- "10" + 5 does not compile either. text (Debug.toString ("1" == "1") ++ " " ++ Debug.toString (1 == 1) ++ " " ++ ("abc" ++ String.fromInt 5) )
== in Elm only compares two values of the same type, so "1" == 1 is not false — it is a compile error saying the two sides do not match. That removes the entire question. One genuine restriction worth knowing: Elm's == refuses to compare functions, and comparing them is a run-time error rather than a compile one, which is the narrow exception to the no-runtime-exceptions rule. Note also that ++ is the only concatenation operator, so the PHP habit of using + for one thing and . for the other simply has no counterpart to confuse.
A function cannot do anything but return a value
Any PHP function may do anything: write a file, log a line, hit the database, mutate a global. The signature says nothing about it. In Elm a function's signature is a complete description of what it does.
<?php function logAndDouble(int $value): int { error_log("doubling {$value}"); // a side effect, invisible in the signature file_put_contents("/tmp/php-elm-audit.txt", "{$value}\n", FILE_APPEND); return $value * 2; } echo logAndDouble(21), "\n";
module Main exposing (main) import Html exposing (Html, text) double : Int -> Int double value = -- There is nowhere to put a log line, a file write, or an HTTP call. -- The signature says Int -> Int, and that is exhaustively what it does. value * 2 main : Html msg main = text (String.fromInt (double 21))
Effects still happen, of course — they are expressed as Cmd values that update returns and the runtime performs, so an HTTP request is a value describing a request rather than a call that makes one. The practical consequence for someone maintaining code is large: a function of type Int -> Int cannot have surprised you, so reviewing it means reading it, and testing it needs no mocks, no fixtures and no database. The cost is that a quick error_log inserted to debug something is not available — Debug.log exists for exactly that and is stripped from optimized builds.
The compiler enforces semantic versioning
Both ecosystems declare dependencies in a file and resolve version ranges. What differs is whether the version number is trustworthy, and Elm is the only major language where it is enforced rather than promised.
<?php // composer.json declares ranges, and you trust the maintainer: // "require": { "guzzlehttp/guzzle": "^7.0" } // // A patch release CAN break you; composer cannot tell. echo "composer require guzzlehttp/guzzle\n";
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = -- elm install elm/http -- -- The package registry DIFFS the public API of the new version against -- the old one and refuses to publish a version number that understates -- the change. A patch release cannot alter a type signature. text "elm install elm/http"
When you publish an Elm package, the tooling compares the new public API against the previous release and computes the version bump itself — add a function and it must be a minor release, change a type signature or remove anything and it must be major. You cannot publish 1.0.1 containing a breaking change, because the tool will not let the number be that. Composer, npm and every other registry take the maintainer's word for it, which is why "patch release broke the build" is an ordinary sentence in PHP and an impossible one in Elm. The cost of this and of the purity rules together is a much smaller ecosystem: there are thousands of Elm packages, not hundreds of thousands, and no package can contain arbitrary JavaScript.
Reaching JavaScript goes through a port
Elm cannot call JavaScript directly. There is no eval, no interop function, and no way for a package to bundle JavaScript. The only channel is a port: you send a value out, and JavaScript listens.
<?php // PHP's escape hatch is the whole rest of the language: // exec(), a PECL extension, FFI, or just require an SDK. $json = json_encode(["event" => "signup"]); echo "would post {$json} to an analytics SDK\n";
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = -- port analytics : String -> Cmd msg (in a port module) -- -- Elm sends a VALUE out; JavaScript subscribes and does the work. -- Nothing JavaScript does can throw into Elm. text "would send an event through a port"
This is the restriction most likely to decide whether Elm fits a project, so it is worth stating plainly rather than defending. Using a JavaScript charting library, a payment widget or an analytics SDK means writing the JavaScript side yourself and passing values across, and there is no shortcut. What the restriction buys is the guarantee everything else on this page rests on: because no Elm code can call arbitrary JavaScript, no Elm code can throw, and the compiler's promises hold for the whole program rather than up to the first library. PHP's escape hatches are unlimited and the trade runs exactly the other way.

Thank you — anything else?