From PHP to Rust: My Journey Building Syndeos with Tauri

Introduction
As a developer who has primarily worked with PHP and TypeScript in Laravel applications, diving into Rust to build a desktop application with Tauri has been both challenging and immensely rewarding. Today, I want to share my experience developing Syndeos, a VPS server management desktop application that brings together the best of web technologies and native performance.
Why Rust and Tauri?
For years, I've been comfortable in the web development ecosystem, building server-rendered applications with Laravel and interactive UIs with React. But I was curious about desktop applications and particularly excited about Tauri's promise: use your web skills to build cross-platform desktop apps with minimal bundle sizes and native performance.
Tauri uses Rust for the backend while allowing frontend developers to use familiar web technologies. For someone coming from PHP, this was a perfect gateway into systems programming without abandoning my existing skills.
Confronting the Borrow Checker
If you've heard anything about Rust, you've probably heard about the borrow checker. It's Rust's mechanism for ensuring memory safety without a garbage collector, and it was the first significant hurdle I faced.
Consider this code from my SSH key service:
pub fn add_ssh_key(conn: &Connection, name: String, path: String, password: String, is_default: bool) -> Result<SshKey, String> {
let now = chrono::Local::now().to_rfc3339();
if is_default {
conn.execute(
"UPDATE ssh_keys SET is_default = 0 WHERE is_default = 1",
[],
).map_err(|e| e.to_string())?;
}
let password_hash = if password.trim().is_empty() {
None
} else {
Some(hasher::hash_password(&password).map_err(|e| e.to_string())?)
};
conn.execute(
"INSERT INTO ssh_keys (name, path, password, is_default, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![name, path, password_hash, is_default, now, now],
).map_err(|e| e.to_string())?;
get_ssh_key(&conn, conn.last_insert_rowid())
}
In PHP, I wouldn't think twice about passing variables around, reusing them, or letting the garbage collector handle memory cleanup. But in Rust, I needed to be conscious of:
- Who owns each variable
- Who can borrow it and how (mutable or immutable)
- How long that borrowing lasts
The &Connection
parameter means we're borrowing the database connection, not taking ownership of it. We're taking full ownership of the name
, path
, and password
strings, allowing us to manipulate them freely. This ownership model initially slowed me down, but it's an investment that pays dividends in code correctness.
Error Handling: From Exceptions to Result Types
In PHP and many other languages, exceptions are the primary error handling mechanism. Rust takes a different approach with its Result<T, E>
type:
pub fn get_ssh_key(conn: &Connection, id: i64) -> Result<SshKey, String> {
conn.query_row(
"SELECT id, name, path, is_default, created_at, updated_at
FROM ssh_keys WHERE id = ?1",
params![id],
|row| Ok(SshKey {
id: Some(row.get(0)?),
name: row.get(1)?,
path: row.get(2)?,
password: None,
is_default: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
})
).map_err(|e| e.to_string())
}
Every operation that could fail returns a Result
that must be handled. The ?
operator is Rust's way of saying "if this fails, return the error early." It feels like a more structured approach to error handling than try/catch blocks.
Using map_err()
to convert database errors to string messages is cleaner than catching exceptions and re-throwing them with new messages in PHP.
The Tauri Commands Layer
One of the most elegant parts of Tauri's architecture is how it bridges the Rust backend with the JavaScript frontend through commands:
#[tauri::command]
pub fn get_ssh_keys(app_handle: AppHandle) -> Result<Vec<SshKey>, String> {
let conn = connection::get(&app_handle)?;
service::get_ssh_keys(&conn)
}
Each command is just a Rust function that's exported to JavaScript. The Tauri framework handles the serialization, making it feel magical when you call these functions from React:
invoke<SshKeys>("get_ssh_keys")
.then((data) => {
setKeys(data || []);
setLoading(false);
})
.catch((error) => {
console.log(error);
setLoading(false);
});
Database Access: From ORM to SQLite Queries
Coming from Laravel's Eloquent ORM, working with raw SQL in Rust's rusqlite
was a back-to-basics experience:
pub fn get_servers(conn: &Connection) -> Result<Vec<Server>, String> {
let mut stmt = conn.prepare("
SELECT id, name, hostname, ip_address, port, username, ssh_key_id, notes, settings, created_at, updated_at
FROM servers
").map_err(|e| e.to_string())?;
let server_iter = stmt.query_map([], |row| {
let settings_str: String = row.get(8)?;
let settings = serde_json::from_str(&settings_str).unwrap_or_else(|_| serde_json::json!({}));
Ok(Server {
id: Some(row.get(0)?),
name: row.get(1)?,
hostname: row.get(2)?,
ip_address: row.get(3)?,
port: row.get(4)?,
username: row.get(5)?,
ssh_key_id: row.get(6)?,
notes: row.get(7)?,
settings,
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
}).map_err(|e| e.to_string())?;
let mut servers = Vec::new();
for server in server_iter {
servers.push(server.map_err(|e| e.to_string())?);
}
Ok(servers)
}
While verbose compared to Eloquent models, this explicitness has advantages. Each field is clearly accounted for, and the types are crystal clear. It's harder to make mistakes when everything is spelled out.
Structuring a Rust Application: The Module System
Rust's module system encourages a well-organized codebase. In Syndeos, I followed this pattern:
src/
├── common/ # Shared utilities
│ └── hasher.rs # Password hashing
├── database/ # Database connections
│ └── connection.rs
├── features/ # Core features
│ ├── server/ # Server management
│ │ ├── commands.rs # Tauri commands
│ │ ├── model.rs # Data models
│ │ └── service.rs # Business logic
│ ├── ssh_key/ # SSH key management
│ └── setting/ # App settings
└── lib.rs # Main library entry
This structure aligns well with MVC principles—each feature has models, services (controllers), and the Tauri commands serve as a kind of API layer.
What's impressive is how Rust's module system enforces these separations through its visibility rules. You have to be explicit about what you're exposing with pub
keywords, which ensures proper encapsulation.
Working with File Systems and Native APIs
One of the most powerful aspects of building with Tauri is access to native APIs through Rust. For example, managing SSH keys involves filesystem operations:
pub fn generate_ssh_key(conn: &Connection, name: String, password: String, is_default: bool) -> Result<SshKey, String> {
let home_dir = dirs::home_dir().ok_or("Could not get home directory")?;
let ssh_dir = home_dir.join(".ssh");
if !ssh_dir.exists() {
fs::create_dir_all(&ssh_dir).map_err(|e| e.to_string())?;
// Set appropriate permissions (unix only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let permissions = fs::Permissions::from_mode(0o700);
fs::set_permissions(&ssh_dir, permissions).map_err(|e| e.to_string())?;
}
}
// ... ssh-keygen process
}
This code shows how Rust handles platform-specific logic with the #[cfg(unix)]
attribute, allowing us to set Unix-specific permissions while maintaining cross-platform compatibility.
State Management: From Laravel to React Context
On the frontend side, I adopted React Contexts for state management:
export function ServerProvider({ children }: { children: React.ReactNode }) {
const [selectedServerId, setSelectedServerId] = useState<number | null>(null);
const [serverData, setServerData] = useState<Server | null>(null);
return (
<ServerContext.Provider
value={{
selectedServerId,
setSelectedServerId,
serverData,
setServerData
}}
>
{children}
</ServerContext.Provider>
);
}
This pattern provides a nice separation of concerns—each component can access only the state it needs without prop drilling. It's somewhat analogous to Laravel's dependency injection container, but adapted for React's component-based architecture.
Lessons Learned: From Web to Desktop Development
Building Syndeos has taught me several valuable lessons:
-
The value of type safety: Rust's strict type system catches errors at compile time that would be runtime errors in PHP.
-
Ownership makes resource management explicit: By forcing you to think about who owns data, Rust prevents many classes of bugs related to memory and resource management.
-
Error handling shouldn't be an afterthought: Rust's
Result
type forces you to consider and handle errors at every step, leading to more robust code. -
Platform-specific considerations matter: Unlike web apps that run in a browser sandbox, desktop apps need to consider filesystem permissions, OS differences, and other platform specifics.
-
UI separation is critical: The clean separation between the Rust backend and React frontend helped maintain a clear architecture, similar to how Laravel separates backend logic from Blade templates.
Conclusion
The journey from PHP and Laravel to Rust and Tauri has been enlightening. While the learning curve was steep—especially grappling with the borrow checker and Rust's strict compiler—the resulting application benefits from increased performance, smaller bundle sizes, and a robust architecture that prevents many common errors.
For web developers curious about systems programming or desktop application development, I highly recommend the Tauri approach. You can leverage your existing web skills while gradually learning the more powerful (and sometimes more challenging) concepts in Rust.
Syndeos is still a work in progress, but building it has already expanded my programming horizons beyond what I thought possible coming from a PHP background. The concepts I've learned—ownership, borrowing, lifetimes, and explicit error handling—have even influenced how I write my PHP and TypeScript code, making me a better developer overall.
If you're considering a similar journey, my advice is simple: embrace the compiler's feedback, take the time to understand ownership, and enjoy the satisfaction of building something that runs blazingly fast on the user's machine without a server in sight.