The way block reward is computed today is :
CAmount blockReward = nFees + GetBlockSubsidy(pindex->nHeight, chainparams.GetConsensus());
Which means that the block reward is the total fees in the block plus the current base block subsidy.
It's definitely possible to change this line to (pseudocode) :
CAmount blockReward = std::min(nFees + GetBlockSubsidy(pindex->nHeight, chainparams.GetConsensus()), 25000000);
This will be a soft fork which doesn't allow the reward to be larger than 0.25 BTC
To explain what I mean in English, the reward is set minimum taken between the current block subsidy + fees, or 0.25 BTC. This is a constraint on the current rules (where the base subsidy is larger than 0.25 BTC), and forward compatible with the period in the future when the base subsidy is lower than 0.25 BTC.
Adding @pieter-wuille's point from the comment, the current rules don't limit a miner in how low they can set their reward to be (where the minimum is zero), only how high. That means that a miner doesn't have to reward themselves with the maximum allowed reward. Such occurences have happened on chain before :
Rootstock accidentally set a zero amount as their reward :
https://www.smartbit.com.au/tx/9bf8853b3a823bbfa1e54017ae11a9e1f4d08a854dcce9f24e08114f2c921182
The first satoshi taken out of money supply :
https://www.smartbit.com.au/tx/5d80a29be1609db91658b401f85921a86ab4755969729b65257651bb9fd2c10d
Maybe I'm not sure what "soft fork" means exactly in this context. But what about a scheme where > 50% of miners agree that they will not mine on top of any block with a reward larger than 0.25 BTC? Non-mining nodes don't have to upgrade; they might accept blocks with higher rewards from non-conforming miners, but those blocks will eventually be orphaned. And because of the 100-block maturation time, no transaction that spends a higher reward will ever be valid. – Nate Eldredge – 2018-12-08T17:50:03.577