Rust for PHP Developers a Practical Translation Guide
PHP developers move from a request-per-process model to long lived services. This guide translates arrays, classes, exceptions and Composer into Rust and covers what changes at runtime.

PHP trained a generation of developers to ship web software fast, and the habits it built are good ones: small functions, a request in and a response out, a package manager that just works. Rust asks for more up front and returns a single binary that is fast, uses little memory and fails at compile time instead of in a log. The biggest shift is not syntax. A PHP script ends when the request ends, and a Rust server keeps running, so everything you stored in a global or a static is now shared by every request at once. This guide translates the PHP you know, one program at a time, and says where the translation stops working. All code was built with rustc 1.99.0, edition 2024, on an Apple M1 Max, and the comparisons ran against PHP 8.4.7 without JIT. Nothing needs a crate.
Arrays split into Vec and HashMap
A PHP array is an ordered hash map that happens to accept integer keys, which is why one type serves as a list, a dictionary, a set and a stack. Rust keeps those jobs apart. A list is Vec<T>, a dictionary is HashMap<K, V>, and each has one element type that the compiler fixes. This program does the three things PHP array code does most: build and sort a list, update a dictionary and read a missing key with a default.
use std::collections::HashMap;
fn main() {
// PHP: $prices = [19.99, 5.00, 42.50];
let mut prices: Vec<f64> = vec![19.99, 5.00, 42.50];
prices.push(8.25);
prices.sort_by(|a, b| a.total_cmp(b));
println!("{prices:?}");
// PHP: $stock = ['apple' => 3, 'pear' => 0];
let mut stock: HashMap<&str, u32> = HashMap::from([("apple", 3), ("pear", 0)]);
*stock.entry("apple").or_insert(0) += 2;
stock.entry("plum").or_insert(7);
let mut keys: Vec<_> = stock.iter().collect();
keys.sort();
println!("{keys:?}");
// PHP: $stock['kiwi'] ?? 0
println!("{}", stock.get("kiwi").copied().unwrap_or(0));
}[5.0, 8.25, 19.99, 42.5]
[("apple", 5), ("pear", 0), ("plum", 7)]
0The comments show the PHP each line replaces. sort_by takes a comparison closure, and total_cmp is used because f64 has no total order, since NaN compares unequal to everything, so sort() without a comparator is not available for floats. The line *stock.entry("apple").or_insert(0) += 2 is the idiom that replaces $stock['apple'] = ($stock['apple'] ?? 0) + 2. The entry call finds the slot once, or_insert creates it with zero if it is empty, and the leading * dereferences the returned mutable reference so you can add to the number in place. The last line is ??: get returns an Option, copied turns the reference into a value and unwrap_or(0) supplies the default.
One difference will surprise you in the output. A HashMap has no defined order, and PHP arrays keep insertion order. The program sorts the entries before printing for that reason. If you want insertion order, the indexmap crate provides it, and if you want sorted keys, BTreeMap is in the standard library. The collections compared post lists which to pick.
The second difference is that a PHP array can hold anything at all in the same list, and a Vec cannot. This is the line a PHP developer writes first when reading a database row:
fn main() {
let row = vec![1, "two", 3.0];
println!("{}", row.len());
}error[E0308]: mismatched types
--> src/main.rs:2:23
|
2 | let row = vec![1, "two", 3.0];
| ^^^^^ expected integer, found `&str`
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.The compiler fixed the element type from the first item, an integer, and rejected the second. The right answer depends on what the row is. A row with known columns is a struct. A value that can legitimately be one of several shapes is an enum, with one variant per shape.
#[derive(Debug)]
enum Cell {
Int(i64),
Text(String),
Float(f64),
}
fn main() {
let row = vec![Cell::Int(1), Cell::Text("two".into()), Cell::Float(3.0)];
for cell in &row {
match cell {
Cell::Int(n) => println!("int {n}"),
Cell::Text(s) => println!("text {s}"),
Cell::Float(f) => println!("float {f}"),
}
}
}int 1
text two
float 3Each Cell variant carries its payload, and the match must cover all three, so adding a fourth variant such as Null sends the compiler to every place that now needs a decision. Note the last line: 3.0 prints as 3 because Display drops a trailing .0, while {:?} would print 3.0. PHP developers who round-trip through var_export will know the feeling. If you need to model data from outside the program, JSON for example, the usual route is the serde crate with a struct or an enum, and serializing JSON with Serde shows that path.
null and exceptions become Option and Result
PHP uses null for absence and exceptions for failure, and the signature of a function usually says neither. In Rust the first is Option<T> and the second is Result<T, E>, and both are part of the type. You have already seen Option in the get calls above. For Result, the equivalent of a function that throws on failure is a function that returns Err, and the ? operator passes it up the way an uncaught exception would, except that it is visible at every call. The guide to Result goes through the details, and error handling in Axum shows how an error type becomes an HTTP status. The rule that carries over from good PHP is the same one: use the failure channel for things the caller can act on, and let genuine bugs crash.
Numbers have a related surprise. A PHP integer that overflows quietly becomes a float. A Rust integer has a fixed size, and what happens on overflow depends on how you build. Try the obvious thing with a one-byte counter.
fn main() {
let level: u8 = 255;
let next = level + 1;
println!("{next}");
}error: this arithmetic operation will overflow
--> src/main.rs:3:16
|
3 | let next = level + 1;
| ^^^^^^^^^ attempt to compute `u8::MAX + 1_u8`, which would overflow
|
= note: `#[deny(arithmetic_overflow)]` on by default
error: aborting due to 1 previous errorThis one never reaches a test. The compiler evaluates the constant expression and rejects it with a lint that is an error by default. When the value is not a constant, a debug build panics at run time on overflow, and a release build wraps around silently, which is the case that causes real bugs. So the standard library gives you the policy as methods, and you choose one.
fn main() {
let level: u8 = std::env::args().count() as u8 + 254;
println!("{:?}", level.checked_add(1));
println!("{}", level.wrapping_add(1));
println!("{}", level.saturating_add(1));
println!("{}", level as u32 + 1);
}None
0
255
256The level is derived from the argument count so the optimizer cannot fold it away. checked_add returns None on overflow, wrapping_add wraps to zero and saturating_add stops at 255. The last line widens first, which is what you do when the answer should simply be 256. Use checked_* for money, quantities and anything a user supplies. The integer overflow post explains the debug and release difference in more depth.
Strings are UTF-8 and do not index
PHP strings are byte arrays, which is why strlen and mb_strlen both exist. Rust has String and &str, both guaranteed UTF-8. len() returns bytes, chars().count() returns Unicode scalar values and there is no $s[3], because a byte offset into UTF-8 can land inside a character. When you only need to read text, take &str in your function parameters and the caller can pass a String or a literal. When you need to own or build text, use String. The String versus str post is the one to read twice.
Classes become structs, impl blocks and traits
A PHP class bundles data, methods, visibility and inheritance. Rust splits those. A struct holds the data, an impl block holds its methods, and a trait plays the role of an interface. There is no class inheritance at all: you share behavior by implementing the same trait on different types, or by composing one struct inside another. The shopping cart below uses a Discount trait the way you would use a PHP interface with two implementations.
trait Discount {
fn apply(&self, subtotal_cents: u64) -> u64;
}
struct Percent(u64);
struct Flat {
cents: u64,
}
impl Discount for Percent {
fn apply(&self, subtotal_cents: u64) -> u64 {
subtotal_cents - subtotal_cents * self.0 / 100
}
}
impl Discount for Flat {
fn apply(&self, subtotal_cents: u64) -> u64 {
subtotal_cents.saturating_sub(self.cents)
}
}
struct Cart {
lines: Vec<(String, u64)>,
discount: Box<dyn Discount>,
}
impl Cart {
fn total(&self) -> u64 {
let subtotal = self.lines.iter().map(|(_, cents)| cents).sum();
self.discount.apply(subtotal)
}
}
fn main() {
let lines = vec![("book".to_string(), 1999), ("pen".to_string(), 349)];
let a = Cart { lines: lines.clone(), discount: Box::new(Percent(10)) };
let b = Cart { lines, discount: Box::new(Flat { cents: 5000 }) };
println!("{} {}", a.total(), b.total());
}2114 0Percent and Flat are unrelated types that both promise an apply method, and Cart stores either one as Box<dyn Discount>, a heap pointer to anything that implements the trait. That is the equivalent of a typed property private Discount $discount and it costs one pointer indirection per call, which is irrelevant here. Money is held as integer cents, and saturating_sub keeps the flat discount from going below zero: the second cart prints 0, not a huge wrapped number. If the set of discounts is closed and known, an enum with a variant per kind is often cleaner than a trait object, and enum dispatch shows when. For the open case, see trait objects.
Visibility also changes. Fields are private to the module by default, and the only keyword is pub. PHP developers used to protected and final will find neither. A module is the unit of privacy, not the type, and the compiler enforces it:
mod shop {
pub struct Cart {
total_cents: u64,
}
impl Cart {
pub fn new() -> Cart {
Cart { total_cents: 0 }
}
}
}
fn main() {
let cart = shop::Cart::new();
println!("{}", cart.total_cents);
}error[E0616]: field `total_cents` of struct `Cart` is private
--> src/main.rs:15:25
|
15 | println!("{}", cart.total_cents);
| ^^^^^^^^^^^ private field
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0616`.The fix is a getter method on the impl inside the module, or making the field pub if it truly is plain data. Prefer the method when the field has an invariant to protect, as a total would. The modules and visibility post covers pub(crate) and the rest of the levels.
The request model: one process, shared state
In PHP-FPM every request starts with an empty slate. Globals, static properties and anything in memory die when the response is sent, so state lives in Redis, the database or the session file, and concurrency is the web server's problem. A Rust server is one process serving all requests, normally on many threads or tasks. State you keep in memory is shared, which is the source of the best performance wins and the hardest new bugs. This server uses only the standard library. It keeps a hit counter and the last three request paths in memory.
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
struct State {
hits: AtomicU64,
last_paths: Mutex<Vec<String>>,
}
fn handle(mut stream: TcpStream, state: &State) -> std::io::Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut request_line = String::new();
reader.read_line(&mut request_line)?;
let path = request_line.split_whitespace().nth(1).unwrap_or("/").to_string();
let n = state.hits.fetch_add(1, Ordering::Relaxed) + 1;
let recent = {
let mut paths = state.last_paths.lock().unwrap();
paths.push(path.clone());
if paths.len() > 3 {
paths.remove(0);
}
paths.join(",")
};
let body = format!("hit {n} on {path}; recent: {recent}\n");
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
}
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:7878")?;
let state = Arc::new(State { hits: AtomicU64::new(0), last_paths: Mutex::new(Vec::new()) });
for stream in listener.incoming() {
let stream = stream?;
let state = Arc::clone(&state);
thread::spawn(move || {
if let Err(e) = handle(stream, &state) {
eprintln!("connection error: {e}");
}
});
}
Ok(())
}Read it from the top. State holds an AtomicU64 for the counter and a Mutex<Vec<String>> for the recent paths. The atomic needs no lock because one add is a single hardware operation. The vector needs a mutex because push and remove together are not. main wraps the state in Arc, a reference-counted pointer that is safe to share across threads, and gives each connection its own clone before it spawns a thread. Inside handle, the path comes from the request line, the counter is incremented, and the lock is held only for the block that edits the vector: the guard drops at the closing brace, before the response is built. That scope is deliberate. Holding a lock while writing to a socket would make every request wait for the slowest client. Start the server and call it four times from another terminal with curl:
hit 1 on /a; recent: /a
hit 2 on /b; recent: /a,/b
hit 3 on /c; recent: /a,/b,/c
hit 4 on /d; recent: /b,/c,/dThe counter and the list persist across requests, which no PHP-FPM worker can do on its own. That is useful for caches, rate limiters and connection pools, and it is also the reason the language insists on proof that the sharing is safe. Remove the Mutex from a program like this and it does not compile. Here is the shape of the bug in a form the compiler catches, with two scoped threads incrementing a plain integer.
use std::thread;
fn main() {
let mut hits = 0;
thread::scope(|s| {
s.spawn(|| hits += 1);
s.spawn(|| hits += 1);
});
println!("{hits}");
}error[E0499]: cannot borrow `hits` as mutable more than once at a time
--> src/main.rs:7:17
|
5 | thread::scope(|s| {
| - has type `&'1 Scope<'1, '_>`
6 | s.spawn(|| hits += 1);
| ---------------------
| | | |
| | | first borrow occurs due to use of `hits` in closure
| | first mutable borrow occurs here
| argument requires that `hits` is borrowed for `'1`
7 | s.spawn(|| hits += 1);
| ^^ ---- second borrow occurs due to use of `hits` in closure
| |
| second mutable borrow occurs here
|
note: requirement that the value outlives `'1` introduced here
--> /rustc/b940084d7eb6.../library/std/src/thread/scoped.rs:203:34
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0499`.Two closures each want a mutable borrow of hits at once, and the error says so. In PHP the equivalent is a race that corrupts a counter once in a million requests. The fix is to wrap the integer in a Mutex so only one thread can mutate it at a time.
use std::sync::Mutex;
use std::thread;
fn main() {
let hits = Mutex::new(0u32);
thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..1000 {
*hits.lock().unwrap() += 1;
}
});
}
});
println!("{}", hits.lock().unwrap());
}4000Four threads each add a thousand, and the total is exactly 4000 every time. thread::scope guarantees the threads finish before the block ends, which is why they may borrow hits from the enclosing function without an Arc. A production service uses an async runtime instead of a thread per connection, and the same state sharing happens through Arc and, in Axum, the State extractor described in sharing state in Axum. The thread-and-lock version above is the one to learn first, because the async code has the same rules with more syntax. The pairing of Arc and Mutex is covered in its own post.
A caveat that matters for PHP developers: this model removes the isolation that FPM gave you for free. A panic in a thread kills that thread, not the process, but a memory leak or a poisoned lock now affects every client until restart. Plan for health checks and a supervisor from the start.
What the move buys: a measurement
The common reason to look at Rust from PHP is cost per request, so here is one data point from a task shaped like real work: counting word frequencies. We generated a 38 MB file with 8 million words drawn from a skewed 50,000-word vocabulary, then wrote the idiomatic version in each language. The Rust version splits on whitespace into borrowed slices and counts them in a HashMap.
use std::collections::HashMap;
use std::fs;
fn main() -> std::io::Result<()> {
let text = fs::read_to_string("words.txt")?;
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
let mut top: Vec<(&str, u32)> = counts.into_iter().collect();
top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
for (w, n) in top.iter().take(3) {
println!("{w} {n}");
}
Ok(())
}The PHP version uses preg_split and an associative array, which is what most PHP code would do.
<?php
$text = file_get_contents('words.txt');
$counts = [];
foreach (preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY) as $w) {
$counts[$w] = ($counts[$w] ?? 0) + 1;
}
uksort($counts, fn($a, $b) => $counts[$b] <=> $counts[$a] ?: strcmp($a, $b));
foreach (array_slice($counts, 0, 3, true) as $w => $n) echo "$w $n\n";Both print the same top three words, w0 702821, w1 350758 and w2 233819. The timings, five runs each:
rust 1.99, rustc -O 0.24 s 0.25 s 0.27 s 0.29 s 0.29 s
php 8.4.7, no JIT 1.34 s 1.37 s 1.39 s 1.42 s 1.42 sRust finished in about a quarter of a second and PHP in about one and a third, so roughly five times as long. The memory difference was larger. Peak resident memory was 44 MB for the Rust binary, which reads the file once and borrows slices out of it, and 453 MB for PHP, which has to hold 8 million strings in an array before counting. With PHP's default memory_limit of 128 MB, the script did not finish at all and died with a fatal error on the preg_split line. We raised the limit to get a number. A streaming PHP version, reading in chunks, would use far less memory and narrow the gap in space but not in time. The honest conclusion is narrow: for in-memory text processing, expect a single-digit speedup and a large memory saving, and expect the gap to be much smaller for code that waits on a database, which is most PHP applications.
Composer becomes Cargo
| PHP | Rust |
|---|---|
composer.json | Cargo.toml |
composer.lock | Cargo.lock |
composer require foo/bar | cargo add bar |
vendor/ | dependency sources in ~/.cargo/registry, build output in target/ |
| PHPUnit | cargo test |
| PHP-CS-Fixer, PHPStan | cargo fmt, cargo clippy |
| PSR-4 autoloading | modules, resolved at compile time |
The practical difference is the build step. PHP has none, so you edit and reload. Rust compiles, and a release build of a mid-sized service takes minutes, so you lean on cargo check and tests while you work. In exchange, a dependency version that conflicts or an API that changed shows up at build time, not on the first request that touches it. A common place to start is a PHP extension or a sidecar service, which lets you move one expensive function without touching the rest. Migrating a service to Rust incrementally covers the options.
Where PHP habits mislead
A few reflexes need unlearning. Do not reach for clone() every time the borrow checker complains, because that copies data you meant to share. Do not use unwrap() as the Rust form of the @ operator, since it panics on failure. Do not build one giant HashMap<String, Value> to mimic an associative array, because the struct you would have written in a typed PHP codebase is the better model here. Do not port a service layer class by class either. A Rust program is usually flatter than a Symfony one, with plain functions that take their dependencies as arguments, and the extra layers you would write for testability are often unnecessary because a trait and a fake already do that job. And do not expect framework magic: routing, validation and dependency injection are libraries you choose, and the structure comes from types and modules instead of container configuration.
When this advice does not apply
If your application is mostly CRUD against a database, the bottleneck is queries, and a rewrite will not change the p95 latency in any visible way. PHP 8.4 with OPcache, a tuned FPM pool and a good index is faster than most teams need. If your team ships features weekly with Laravel or Symfony, the productivity of those frameworks, from queues to the ORM to the ecosystem, has no Rust equivalent of the same breadth. And if state in memory worries you, Octane and Swoole already give PHP a long-running model with some of the same sharing problems. Rust earns its keep where memory and CPU cost money, where input is hostile and parsing it is the job, or where a worker runs for weeks. For those, start from the language tour and build something small, such as the server above, before deciding.


