The Pi Car: A Web-Controlled Robot for a Seven-Year-Old
· 5 min read · raspberry pi, robotics, php, vue, pi car

When I was a kid, nothing in the house with a screw in it was safe. That turned into building things instead of taking them apart, and in the fall of 2016 it turned into this: a small four-wheel car my oldest daughter, seven at the time, could drive from an iPad.
I wrote three posts about it back then and finished none of them. This is the one post I should have written, with the parts I skipped and the parts I got wrong.
The build
I'd always reached for Arduino for this kind of thing. This time I wanted the car to serve its own web page, so the controller had to be a real computer. A Raspberry Pi 3 was about $30 and came with Linux, Wi-Fi, HDMI and four USB ports. That decision was the whole project.
The drivetrain is four cheap DC gear motors, one per wheel, driven by an Adafruit DC+Stepper Motor HAT over I2C at address 0x60. No L298N here; that came later, for the rover, when I needed motors the HAT couldn't feed. The car is a skid-steer: there's no steering linkage, you turn by running one side slower than the other.
The part that was actually interesting
The web UI lets you set three things: a heading from -90° to +90°, a speed from 0 to 255, and a duration. You can fire a command immediately or drag it into a queue and run the queue as a little program. That queue was the feature that got used.
Heading is the only real math in the project. With skid steering, "turn 45° right" means "run the right side slower." I mapped heading to a speed reduction on the inside pair:
# speed - ( ( |heading| / 90 ) * speed )
reduced = int(speed - ((math.fabs(heading) / 90) * speed))
At heading 0 both sides get full speed. At ±90 the inside pair gets zero and the car pivots. It isn't linear in any physical sense, but it was intuitive enough that a kid could steer it on the first try.
Duration is in hundredths of a second, because I wanted an integer slider and time.sleep(0.01 * moment) was easy.
The stack, and the mistake in it
The Pi runs nginx with PHP-FPM. The page is one index.html with Bootstrap, Vue 1 and vue-resource; the Vue app holds the queue and POSTs each command to /. The backend is a Lumen app with exactly two routes: GET / renders the page, POST / takes a command.
My first plan was to drive the GPIO from PHP. I pulled in piphp/gpio and calcinai/phpi, wrote a Motor class with a pin map for all four motors and a Pin class that shelled out to gpio -g write. It worked for "forward for one second." Then I looked at what I'd built: a PHP object model wrapping a shell call to a C binary to toggle a pin, so that I could then write PWM speed control on top of it, in PHP, from a web request. That's not a robot, that's a cry for help.
So I threw it out and let each language do the thing it's good at. Python owns the hardware: one script, motion.py, takes --command, --speed, --moment and --heading on the command line, talks to the HAT through Adafruit's library, and registers an atexit handler that releases all four motors so a crashed script can't leave the car driving into a wall. PHP owns the web: it validates the request, builds an escapeshellcmd'd command line, and runs it with nohup … & echo $! so it can hand back a PID and answer the browser without waiting for the motion to finish.
A Process class tracks that PID and can kill it, which is how "stop" works. It's a sudo python call from a web server. It was fine on a car in my living room and I would not put it on anything that faces the internet.
What I never wrote up
The 2016 posts stopped at "replace the PHP echo with {{ ip_address }}." What they promised and never delivered:
- Wiring the buttons to methods. Every button just pushes an object like
{command, heading, speed, moment}onto an array in Vue'sdata. "Run" POSTs the head of the array; the queue is that array rendered withv-for. There's no state machine. There didn't need to be. - Server-side validation. Whitelist the command name against
['FORWARD', 'REVERSE', 'LEFT', 'RIGHT'], clamp the three integers, reject anything else. Ten lines. - Talking to the motor HAT over I2C. The honest answer is that Adafruit's library does all of it and the only thing worth knowing is
addr=0x60and to callRELEASEon exit.
Where it is now
September 2026. The car is in a box somewhere in the garage, hardware unchanged from 2016. The repo has five commits: one "Synching from Pi" and four cleanup deletes, including a .bash_history I'd accidentally committed. My daughter is seventeen. The Pi 3 in that box would be replaced by a Pi Zero 2 W today and the Lumen app by twenty lines of Flask, but the heading formula would stay exactly as it is.