EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / DirectAdmin / tinker Sends Mail but the Web Can't — proc_open Disabled in FPM
DirectAdmin

tinker Sends Mail but the Web Can't — proc_open Disabled in FPM

by Ton-Or ·8 Oct 2026·9 min read
tinker Sends Mail but the Web Can't — proc_open Disabled in FPM

A passing test in the wrong environment is worse than no test at all, because it makes me cross the real culprit off the suspect list in the first hour.

I recently tracked down a contact form on a Laravel site hosted on DirectAdmin that would not send mail. It should have taken 20 minutes but ate up half a day. It took that long not because it was difficult, but because I tested with php artisan tinker at the start and it sent successfully. The mail actually arrived in the inbox, so I crossed the transport off my suspect list and spent a long time looking elsewhere.

It turned out the transport was the problem, and tinker had not lied. It told the truth about its own environment, which was not the environment serving the website. In this article, I cover only the transport layer. I already wrote separately about the bot check that blocked the request earlier in Laravel Behind a Proxy Sees the IP as 127.0.0.1. That article stops just before the Mail::send line. This one covers what happened next.

All hostnames, domains, and mailboxes in this article are example values.

Symptoms: Works from CLI, Silent from the Web

Tested pathtinkerphp CLI->CLI php.iniAll functions available->sendmailSends successfully
Actual user pathWeb formPOST /contact->PHP-FPM pooldisable_functions->proc_open missingCannot start child process->Nothing reaches mail log

What I saw was the form returning a 302 back to the same page, with no error message and no mail. The most misleading part was that the server's mail log had no entries at all during that time. Normally, if the app sends something and it gets rejected, there should be a trace of the attempt. Having nothing at all meant the send operation had never made it out of the app.

# มีคำขอเข้ามาถึงแอปไหม แล้วแอปตอบอะไร
grep 'POST /contact' ~/domains/app.example.com/logs/*access.log | tail -3
# "POST /contact HTTP/2.0" 302 ...

# มีร่องรอยการส่งไหม
sudo grep '14:1[0-9]' /var/log/exim/mainlog | grep -i contact
# (ว่างเปล่า)

Why CLI and FPM Are Not the Same PHP Environment

PHP on the same machine reads different sets of configuration files depending on how it is invoked. The instance running from the command line reads the CLI configuration, while the instance serving the website reads the configuration for the pool that account uses. The setting that most often differs on hosting shared by multiple accounts is disable_functions. Administrators disable functions that can start child processes on the web side to prevent scripts planted in someone's website from executing system commands.

# ฝั่ง CLI
php -i | grep '^disable_functions'
# disable_functions => no value => no value

# ฝั่งเว็บ ต้องถามผ่าน request เท่านั้น
printf '<?php var_dump(function_exists("proc_open"));' > ~/domains/app.example.com/public_html/_p.php
curl -s https://app.example.com/_p.php     # bool(false)
rm -f ~/domains/app.example.com/public_html/_p.php

Laravel configured with MAIL_MAILER=sendmail sends mail by starting the machine's sendmail program and feeding the message into stdin, which requires proc_open. When that function is unavailable on the web side, it fails before it can talk to the machine's mail system — exactly matching the empty mail log.

Do not trust tinker for environment-dependent work

tinker, artisan commands, and cron jobs all run on CLI, where all functions are available, and often run as a different user from the website. For anything involving file permissions, disabled functions, environment variables, or memory limits, never draw conclusions from CLI test results. Test through the same path actual users take.

Test Through the Right Path — Boot the Actual App and Run as the Web User

Instead of tinker, I wrote a short script that fully boots the framework and calls the actual mail-sending path, then ran it as the user who owns the website. This gets much closer to the real setup and lets me read the thrown exception directly.

<?php
// _mailtest.php — วางในโฟลเดอร์แอป ใช้เสร็จลบ
require __DIR__ . '/vendor/autoload.php';
$app = require __DIR__ . '/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

try {
    Illuminate\Support\Facades\Mail::raw('ทดสอบเส้นทางส่งเมลของแอป', function ($m) {
        $m->to('[email protected]')->subject('MAILTEST');
    });
    echo "sent\n";
} catch (\Throwable $e) {
    echo 'ERR: ' . get_class($e) . ': ' . $e->getMessage() . "\n";
}
su - appuser -c 'php ~/app/_mailtest.php'
# ERR: Symfony\Component\Mailer\Exception\TransportException:
#      Call to undefined function Symfony\Component\Process\proc_open()
rm -f ~appuser/app/_mailtest.php

That line was the entire answer to this case, and it had never appeared when submitting the form because the controller wrapped the call in a try/catch and logged at the info level, while .env had LOG_LEVEL=error. That silently discarded the message at yet another layer.

LOG_LEVEL That Swallows Your Own Logs

If diagnostic code I just added "shows nothing at all," I check LOG_LEVEL before concluding that the code was never called. Temporarily set it to debug, finish testing, then change it back immediately, because debug on a production machine makes logs grow very quickly and includes user data.

Three Approaches I Tried That Failed

Before finding a working configuration, I went down three wrong paths. I have kept them here to show how each one failed, so there is no need to waste time repeating them.

Failed

Three Commonly Recommended Configurations

sendmail fails because proc_open is disabled, with no entries in the mail log · smtp to 127.0.0.1 on port 25 fails when upgrading to TLS because the certificate contains the hostname, not 127.0.0.1 · smtp to the hostname on port 25 without authentication is rejected with 550 because connections through the public IP are not trusted to relay mail

Worked

SMTP AUTH on Port 587

Connect to the mail server hostname listed in the certificate, upgrade to TLS, then log in with a mailbox account on my own domain. The system treats this as an authenticated sender and allows relaying as usual.

The two intermediate approaches produced the following errors. Once I saw the messages, they pointed directly to what needed fixing.

# ต่อ 127.0.0.1:25 แล้วสั่ง STARTTLS
Peer certificate CN=`mail.example.com' did not match expected CN=`127.0.0.1'

# ต่อพอร์ต 25 จากขานอก โดยไม่ล็อกอิน
550 Sender verify failed
550 relay not permitted

A Working Configuration for DirectAdmin Hosting

MAIL_MAILER=smtp
MAIL_HOST=mail.example.com     # ต้องตรงกับชื่อในใบรับรองของเซิร์ฟเวอร์เมล
MAIL_PORT=587
MAIL_ENCRYPTION=tls
[email protected]
MAIL_PASSWORD=<PASSWORD>
[email protected]
MAIL_FROM_NAME="${APP_NAME}"
[email protected]

Test again with the same script, then check the mail log to confirm that it completed the full path. Do not stop at the word sent on the screen.

su - appuser -c 'php ~/app/_mailtest.php'      # sent

sudo grep 'MAILTEST' -A2 /var/log/exim/mainlog | tail -6
# <= [email protected] H=(app) P=esmtpsa A=login:[email protected]
# => [email protected] R=send_to_gateway T=remote_smtp ... C="250 OK id=..."

These two lines are the evidence I accept. The first shows that the mail system accepted the message and identified it as an authenticated submission. The second shows successful delivery to the external destination, along with its response code. If only the first line appears, the message is stuck in the local queue. That is a different problem.

From Must Use My Domain; the Submitter's Email Is Only Reply-To

A common mistake with contact forms is putting the email address the user entered into From to make replying easier. This has the opposite effect, because my server is not authorized to send on behalf of someone else's domain. When the destination checks, validation fails, and it sends the message to trash or rejects it outright. The result is "the form sent it, but it never arrived," which is very hard to diagnose because my side reports success every time.

The correct approach is to always use a mailbox on my domain for From and put the submitter's email address in Reply-To. This gives the same experience when clicking reply without interfering with authorization to send on behalf of a domain.

// app/Mail/ContactMessage.php
public function envelope(): Envelope
{
    return new Envelope(
        from: new Address(
            config('mail.from.address'),
            config('app.name'),
        ),
        replyTo: [new Address($this->data['email'], $this->data['name'])],
        subject: 'ข้อความจากฟอร์มติดต่อ',
    );
}
4 layersto peel back
0log entries
587working port
1 mailboxsending-only account

Create a Sending-Only Account and Store Its Password Properly

I do not use a real person's mailbox as the app's sending account, because its password has to live in a configuration file on the machine, and the day that mailbox's password changes, the app will silently stop sending without anyone knowing. I create a separate account used only for sending, set a long password, and do not connect it to anything else.

  1. Create a separate mailbox named noreply on that domain Set a random password at least 24 characters long. Do not reuse it anywhere, and no one needs to memorize it because nobody logs in to this mailbox.
  2. Forward mail that comes back to this mailbox to a mailbox someone actually uses Otherwise, bounced messages will pile up in a mailbox nobody opens until it reaches its quota, potentially preventing the entire domain from receiving mail.
  3. Store the password only in .env with permissions set to 600 Do not put it in code, commit it, or leave it in shared work notes. When writing an article or showing it to someone else, always replace it with a placeholder.
chmod 600 ~/app/.env
grep -c '^MAIL_PASSWORD=' ~/app/.env      # 1
php artisan config:cache                   # ค่าเมลถูกอ่านผ่าน config ไม่ใช่ env() สด

I took one lesson from this case: test only through the same path actual users take. Convenient tools like tinker are good for trying out logic, but not for confirming that something dependent on the environment works. If the form is still silent even with the transport configured correctly, go back to the stage before Mail::send that I covered in the article linked above, because these two cases produce exactly the same symptoms on the website. If I missed anything in the explanation or made a typo, I will come back and fix it later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first