Jump to content

Custom Classes for Page objects - The Discussion


szabesz
 Share

Recommended Posts

Hello PW fans,

I open this topic to pick up where Ryan left off in his introductory article.

Note that beside the technique detailed in Ryan's article, there is another supported technique to use custom page classes (which is also mentioned in his article briefly), and you can learn even more about that one by – for example – reading this closed issue.

I'm in the process of completely re-thinking the way I implement sites so I am interested in how others integrate custom page classes in their codebase.

Let's use this forum thread as a general discussion board of this topic.

Link to comment
Share on other sites

And here comes the first talking point I'd like to open...

Ryan in his introductory article did not mention whether there is a significant difference between "DefaultPage as a direct subclass" of Page class and "other subclasses of DefaultPage". He also did not mention anything about the recommended way of implementing our own custom "initialization" method for the object, in case we need to perform such a thing.

EDIT on 2021-03-28 starts ------------------------------

Taking a look at wire/core/Templates.php it is the getPageClass() method which determines the class of a page. Here, $corePageClass = __NAMESPACE__ . "\\Page"; is used to set ProcessWire\\Page as expected, except when there is a custom page class being used.

Then, PW checks if a page class has been set "in the admin" and – if it is valid to use it – then uses that one instead. If no valid custom page class has been found so far, then PW looks for a valid custom page class supported by the new technique of PW 3.0.152+ (that is: extending it in our own client code) and this is where the class name ProcessWire\\DefaultPage is hardcoded into PW like this: $defaultPageClass = __NAMESPACE__ . "\\DefaultPage";. If this class is used/exits as a custom page class, then it is set to take over the pace of the core Page class, like this: $this->pageClassNames[0] = $defaultPageClass; and afterwards: $pageClass = $this->pageClassNames[0];

So my understanding is that by using the name DefaultPage to extend Page, one can "ask" ProcessWire to use it as the bases of almost all page objects, except for users, roles and permissions which are treated differently. Taking advantage of the possibility of using DefaultPage is optional of course, one can just simply extend Page by using any other arbitrary names, but the string literal DefaultPage named subclass is treated as the new core class in case one needs to add some custom behavior to all  "non-user related" pages.

Correct me if I am wrong but this is what I gathered sot far....

EDIT on 2021-03-28 ends ------------------------------

What I mean is the following:

//this direct subclass of Page is treated differently
class DefaultPage extends Page {}

//subclass of DefaultPage 
class ArticlesPage extends DefaultPage {}

Ryan in his article suggests that using DefaultPage as a subclasses can be used. However, I found a shortcoming when relying solely on DefaultPage.

Let's consider this:

//direct subclass
class DefaultPage extends Page {
	public function ___loaded() {
		/*
		 *___loaded() is called when the page is "prepared" by PW, having all its properties loaded from the database.
		 * Because of this, it can perhaps be used as an init() method, so that 
		 * we might further initialize the object before actually using it in our own "client code".
		*/
	}
}

The issue with using loaded() to do our own additional initializations is that in the case of DefaultPage this method gets called for ALL the pages that are instantiated during a normal HTTP request. What I mean is that any code placed into loaded() will be executed for all sorts of pages, not just for our humble DefaultPage object we wanted to instantiate in the first place. Even worse – for some reason unknown to me –, the same DefaultPage object's loaded() method will be executed twice during a single HTTP request of the frontend. All this behavior renders loaded() unable to fulfill the role of an init() method I am looking for.

However, I found that the subclasses of DefaultPage work differently, let's say "normally". While DefaultPage takes the place of Page as far as ProcessWire is concerned, any subclass of it does not. This is a very important difference, because the following one behaves differently:

//subclass of DefaultPage
class ArticlesPage extends DefaultPage {
	public function ___loaded() {
		/*
		 * Code placed here always runs just once during a normal HTTP request.
		*/
	}
}

In this case loaded() only gets called by ProcessWire when our own client code triggers the instantiation of the custom page object in any way (for example when the related page is rendered by requesting it in the browser). And in this case no other custom page objects's loaded() method gets called. Moreover, trying to use Tracy (eg. a bd() call) in the loaded() method of DefaultPage is not possible, however, Tracy is readily useable in the loaded() method of a subclass of DefaultPage.

To summarize: I found that in the case of a DefaultPage I cannot use loaded() as an "init() method". However, in the case of any further subclasses of DefaultPage, it looks like it is fine to rely on loaded() because it is called only once (after the object has been prepared for us by PW).

I'm interested in hearing how others implement a custom initialization method for their custom page objects. Maybe there are better ways to do it than relying on loaded()? Is using loaded() for such a thing is a good idea in the first place?

//de

  • Like 2
Link to comment
Share on other sites

I'm all in on pageClasses and it has totally changed the way I'm building apps with processwire ? I'm even rewriting old projects step by step to make my code better readable and maintainable. The great thing is that this does not break any existing code - you can move hooks step by step into the new pageClasses as dedicated methods of that pagetype.

I have showcased my way of doing what you are asking for in 2 posts starting here: https://processwire.com/talk/topic/21212-rockmigrations-easy-migrations-from-devstaging-to-live-server/?do=findComment&comment=212491

I'd recommend triggering a method called "ready" only on the PW "ready" event, not on "loaded" - that's something different and if you create a method called "ready" that is actually triggered on "loaded" you'll likely produce code that might at some day produce hard to find bugs... Same goes for "init".

I'd be interested what Ryan says to this topic. The reason why ready() and init() of custom page classes are not triggered is obviously because custom page classes are not autoload modules. They are requested on demand and you can therefore not tell when or if they get loaded. I guess that's to minimize the footprint of every request as much as possible. In my modules I simply use RockMigrations class loader to load all my custom pageClasses: https://github.com/BernhardBaumrock/RockMigrations/blob/2d85e460ce5aa394480906ad5b41cd1a0e86d0fe/RockMigrations.module.php#L1991-L2003

That might produce a little overhead, but I don't think that this get's noticable any time soon so the benefits outweigh by far.

  • Like 2
Link to comment
Share on other sites

Thanks for chiming in, Bernhard!

I keep an eye on your examples, thanks for sharing! For example:

2 hours ago, bernhard said:

I'd recommend triggering a method called "ready" only on the PW "ready" event, not on "loaded" - that's something different and if you create a method called "ready" that is actually triggered on "loaded" you'll likely produce code that might at some day produce hard to find bugs... Same goes for "init".

Can you please elaborate on this in a bit more detail? Maybe with some examples? I'm not quite getting it. 

Edited by szabesz
...thanks for sharing...
Link to comment
Share on other sites

In Page.php we have the hookable loaded method:

	/**
	 * For hooks to listen to, triggered when page is loaded and ready
	 * 
	 * #pw-hooker
	 *
	 */
	public function ___loaded() { }

It is called on these spots in PW:

XlpctoC.png

X1D1GQn.png

So this means that loaded() is triggerd on a page find operation:

X8vjGu6.png

I'm not 100% sure if that means that it is called on every page find or if PW's internal cache somehow plays a role, but as long as the page is not loaded, the loaded hook will not trigger.

If you look at the docs of modules you see that init() is called immediately when the module is loaded and ready() is called when the api is ready. This can make an important difference sometimes where hooks work in init() but not in ready() and vice versa.

Tr4S6m0.png

Page objects don't have these methods though. These are all the hookable methods of the Page baseclass:

oT1xx4z.png

Additionally you have hookable methods in PagePermissions.module:

HPLOYDw.png

And several others ? 

NlffTEK.png

But no init() and ready(). Because init() and ready() are a concept of PW modules:

EcpM0FZ.png

My point is that loaded() of a page might only be triggered in very special cases. Imagine a module that loads a page only on uninstall() of the module. Then the loaded() event of your pageClass would happen very late in the request. But if you use custom page classes in an object oriented way then they become much more like a PW module and then I think it makes sense to stick to the PW module naming convention and that means that init() is triggered on module/pageclass init - just like you had the code in init.php. That IMHO does also mean, that this code does get executed on EVERY request.

It does NOT mean, that the code is executed only ONCE - that's a matter of the "singular" setting of a module. See examples below.

Code in ready() on the other hand is just like code in ready.php - it fires once, but later in the process, when the API is ready and all other init() methods of all modules and pageclasses have been triggered.

Try this /site/modules/Test.module.php

<?php namespace ProcessWire;
class Test extends WireData implements Module {

  public static function getModuleInfo() {
    return [
      'title' => 'Test',
      'version' => '0.0.1',
      'summary' => 'Your module description',
      'autoload' => false,
      'singular' => false,
      'icon' => 'smile-o',
      'requires' => [],
      'installs' => [],
    ];
  }

  public function init() {
    bd('test init');
  }

  public function ready() {
    bd('test ready');
  }
}

Install the module and then call it from the tracy console:

CFQttWR.png

It only fires init() but not ready() - because when loading the module PW's ready event has already been triggered.

Now make that module autoload, do a modules refresh and fire the console code again:

z9B97V6.png

You see the difference? ? 

Now just for fun we make the module "singular":

LTdSsYA.png

If I happen to need something to execute on every load of the pageClass I use the class constructor:

HiHxiGI.png

This means that my custom pageclass always has the correct template and does always live under the correct parent in the pagetree.

As you can see I define all my fields as class constants. This makes the code so much more readable and also makes typos nearly impossible. It might feel like overhead, but actually it makes me a lot more efficient as well:

jMHlice.gif

So I'm actually using most if not all of my page classes as something like a singular autoload module. Overhead? Maybe a little. Problem? I think/hope no ? 

Hope that helps. Suggestions for improving this further always welcome ? 

  • Like 4
Link to comment
Share on other sites

Thanks Bernhard! Probably it was too late last night that's why I did not get that you were referring to modules, as opposed to using custom page classes for the frontend. My initial question about initializing such instance of the custom page classes was about the frontend only ones, that's what Ryan used to introduced them but he left us figure ourselves where to go from the basics.

2 hours ago, bernhard said:

Page objects don't have these methods though.....

That's my main issue. 

2 hours ago, bernhard said:

My point is that loaded() of a page might only be triggered in very special cases. Imagine a module that loads a page only on uninstall() of the module.

Again, I was not referring to modules, that's clearly a different story. As far as the frontend is concerned, I found that ___loaded() gets called only once for each object instance an only when PW first instantiates the object. I will also look into the core where ___loaded() gets called to figure out more, but as far as the frontend is concerned, so far ___loaded() seems to be a good candidate to perform some custom initialization.

Also, regarding frontend custom page classes, placing this into the constructor:

$this->wire->addHookAfter("Pages::saveReady", $this, "onSaveReady");

seems to work as expected. On each page save, only the onSaveReady() method of the given instances are called (the ones that belong to the class), there seems to be no need to use filters to determine the object's class we are interested in.

Again, all this applies to "subclasses of a direct subclass" on the frontend as I explained above, and doing the same for a direct subclass works differently, probably because the direct subclass takes the place of Page, and from that point on all PW Pages are "DefaultPage"-es, whereas subclasses of DefaultPage are not.

I hope that makes sense...

Link to comment
Share on other sites

"regarding frontend custom page classes"
Sorry for the confusing wording, what I mean is that I was not trying to compare the ___loaded() method of custom page classes to the the init() and ready() methods of a module's class and I am getting a bit confused that you started to discuss those methods as well, because module classes and custom page classes re unrelated in our case, aren't they?

Link to comment
Share on other sites

I just want to signal that I edited my post above, adding this:

Taking a look at wire/core/Templates.php it is the getPageClass() method which determines the class of a page. Here, $corePageClass = __NAMESPACE__ . "\\Page"; is used to set ProcessWire\\Page as expected, except when there is a custom page class being used.

Then, PW checks if a page class has been set "in the admin" and – if it is valid to use it – then uses that one instead. If no valid custom page class has been found so far, then PW looks for a valid custom page class supported by the new technique of PW 3.0.152+ (that is: extending it in our own client code) and this is where the class name ProcessWire\\DefaultPage is hardcoded into PW like this: $defaultPageClass = __NAMESPACE__ . "\\DefaultPage";. If this class is used/exits as a custom page class, then it is set to take over the pace of the core Page class, like this: $this->pageClassNames[0] = $defaultPageClass; and afterwards: $pageClass = $this->pageClassNames[0];

So my understanding is that by using the name DefaultPage to extend Page, one can "ask" ProcessWire to use it as the bases of almost all page objects, except for users, roles and permissions which are treated differently. Taking advantage of the possibility of using DefaultPage is optional of course, one can just simply extend Page by using any other arbitrary names, but the string literal DefaultPage named subclass is treated as the new core class in case one needs to add some custom behavior to all  "non-user related" pages.

Correct me if I am wrong but this is what I gathered sot far....

Link to comment
Share on other sites

18 hours ago, szabesz said:

I was not trying to compare the ___loaded() method of custom page classes to the the init() and ready()

But I was ? And I tried to explain why...

18 hours ago, szabesz said:

because module classes and custom page classes re unrelated in our case, aren't they?

IMHO not

On 3/26/2021 at 6:47 PM, szabesz said:

I'm in the process of completely re-thinking the way I implement sites so I am interested in how others integrate custom page classes in their codebase.

That's what I was trying to explain. I'm using custom page classes in an object oriented way (similar to PW modules). And that's great because you have all your code pieces in one place - namely in one single php file of your custom page class.

Let's say we build a webpage about cats.

Step one: Create a custom page class and place it in /site/classes

<?php namespace ProcessWire;
class Cat extends Page {
}

Now all cats in our system are of type "Cat", and that's great!

Now we want a custom headline for every cat on our frontend based on the title field, easy:

<?php namespace ProcessWire;
class Cat extends Page {

  public function headline() {
    if(!$this->title) return "This cat has no name";
    return "Details for Cat '{$this->title}'";
  }

}

In your template

<h1><?= $page->headline() ?></h1>

And that you'll do for any kind of business logic your application needs.

Now what if we wanted to manage thousands of cats and we wanted every cat have a unique 4-letter page name instead of one based on the title (to prevent ugly suffix urls like catname-1, catname-2 etc)?

We could do that easily with a hook. In the past one might have put the hook into ready.php and after time your ready.php grows and grows and grows and some day you totally lose control over your hooks and over your software.

Not if you place them where they belong: Into the page class to the cat object:

<?php namespace ProcessWire;
class Cat extends Page {
  
  public function init() {
    $tpl = "template=cat";
    $this->addHookAfter("Pages::saveReady($tpl,id=0)", $this, "onCreate");
  }

  public function headline() {
    if(!$this->title) return "This cat has no name";
    return "Details for Cat '{$this->title}'";
  }

  public function onCreate($event) {
    $page = $event->arguments(0);
    $page->name = $event->pages->names()->uniqueRandomPageName(4);
  }

}

That's how you'd attach a hook in an autoload module, and that's what I suggest: Structure your custom page class just like an autoload pw module. Apply the same principles and your code will get better readable, better understandable and better maintainable.

Now the only problem ist, that init() of the page class does not get called like it gets called in an autoload module. Neither does ready(). But it's simple to overcome this - just place this in init.php

$cat = new Cat();
$cat->init();

This triggers the init method of your cat on INIT of PW and it attaches all hooks of your cat page class as if they where placed in init.php; The same technique can be used for ready() in ready.php;

That does load one instance of cat into memory though. That's a little overhead. IMHO it's worth the overhead, but I can only guess that the overhead is very small... Maybe one could also use loaded() for that, I don't know and I'd have to investigate on this. Maybe someone can try and share their findings ? 

  • Like 4
Link to comment
Share on other sites

2 hours ago, bernhard said:

$cat = new Cat();
$cat->init();

This triggers the init method of your cat on INIT of PW and it attaches all hooks of your cat page class as if they where placed in init.php; The same technique can be used for ready() in ready.php;

In my setup that works with init, but not with ready (Error: WireDataDB sourceID must be greater than 0).

Link to comment
Share on other sites

36 minutes ago, 3fingers said:

...I don't get what is the purpose to set id=0 ? 
Would you explain it please? ?

Sure ? That does simply mean that the hook runs only for cats (template=cat) and it only runs for pages that have no ID yet, meaning it runs only when the page is created (before it is saved the first time). That means we only set the page name before the first save and then leave it untouched on every following save.

Edit: I renamed the method to "onCreate" to make it more obvious!

Link to comment
Share on other sites

31 minutes ago, MarkE said:

In my setup that works with init, but not with ready (Error: WireDataDB sourceID must be greater than 0).

You must have something else going on. I just tried it with a custom HomePage class in site/classes/HomePage.php

<?php namespace ProcessWire;
class HomePage extends Page {
  public function ready() {
    bd('ready!');
  }
}

And in ready.php

$p = new HomePage();
$p->ready();

And it properly dumps "ready!" to the tracy bar ? 

  • Thanks 1
Link to comment
Share on other sites

5 hours ago, bernhard said:

But I was ? And I tried to explain why...

Yeah, I got that and thank you for that, however, I'm not keen on implementing modules for doing frontend development. Why? It is simply my personal preference. I perfectly understand if you build upon modules even for the frontend, however, that's just simply not my mindset.

I appreciate that you spend the time explaining the benefits, whoever OOP can be done for the frontend even without putting any code into modules. The entry point is the page being rendered, from that point on, I can instantiate any objects I need to, there is no need for a module to do that. So that is why it is very important for me to be able to pinpoint the method that can be used to do custom initializations. So far I have not found any issues with using loaded(). If I find any drawbacks, then I will certainly ask for more help and ideas.

Link to comment
Share on other sites

14 hours ago, szabesz said:

I appreciate that you spend the time explaining the benefits, whoever OOP can be done for the frontend even without putting any code into modules. The entry point is the page being rendered, from that point on, I can instantiate any objects I need to, there is no need for a module to do that.

I have never claimed anything different. That's why I think several of your statements sound like you did not get what I was trying to say even though you say you did. But it does also not sound like you want to hear another explanation so we can leave it with that ? Using custom page classes is great with or without using my technique ? 

  • Like 2
Link to comment
Share on other sites

4 hours ago, bernhard said:

But it does also not sound like you want to hear another explanation so we can leave it with that

Not at all, it is just that – as I have wrote somewhere above – I keep an eye on your posts so I have read them all because I am interested. Especially as we are indeed talking about the same topic, it is just that we prefer somewhat different approaches to achieve something pretty similar, and I have already taken notes of your "tips and tricks". Thanks once more!

Edited by szabesz
typos
  • Like 1
Link to comment
Share on other sites

  • 2 years later...
5 hours ago, bernhard said:

@adrian do you have any reproducible example?

Hi @bernhard - not really (and I am not sure it's actually related to page classes). I just came across that error for the first time and found Mark's comment about it here and thought I'd see if there was an already determined cause / solution.

  • Like 1
Link to comment
Share on other sites

Just a follow up to note that my issue with that "WireDataDB sourceID must be greater than 0" error was due to a hook on Pages::saveReady that was setting $page->meta even when the ID of a recently cloned page was still 0. Not something anyone else is likely to come across, but just in case, now you know to be aware of it :)

  • Like 1
Link to comment
Share on other sites

On 6/6/2023 at 4:32 AM, adrian said:

did you ever solve the "WireDataDB sourceID must be greater than 0" error?

@adrianI think the answer was given later - hooks need to be bypassed if null page

Also I did:

if(isset($p) and $p and $p->id and method_exists($p, 'ready')) $p->ready();

I can't recall now what actually fixed it!

Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
 Share

  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...